power-loop 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- power_loop-0.2.0/LICENSE +21 -0
- power_loop-0.2.0/PKG-INFO +632 -0
- power_loop-0.2.0/README.md +595 -0
- power_loop-0.2.0/llm_client/__init__.py +0 -0
- power_loop-0.2.0/llm_client/capabilities.py +162 -0
- power_loop-0.2.0/llm_client/interface.py +470 -0
- power_loop-0.2.0/llm_client/llm_factory.py +981 -0
- power_loop-0.2.0/llm_client/llm_tooling.py +645 -0
- power_loop-0.2.0/llm_client/llm_utils.py +205 -0
- power_loop-0.2.0/llm_client/multimodal.py +237 -0
- power_loop-0.2.0/llm_client/qwen_image.py +576 -0
- power_loop-0.2.0/llm_client/web_search.py +149 -0
- power_loop-0.2.0/power_loop/__init__.py +326 -0
- power_loop-0.2.0/power_loop/agent/__init__.py +6 -0
- power_loop-0.2.0/power_loop/agent/sink.py +247 -0
- power_loop-0.2.0/power_loop/agent/stateful_loop.py +363 -0
- power_loop-0.2.0/power_loop/agent/system_prompt.py +396 -0
- power_loop-0.2.0/power_loop/agent/types.py +41 -0
- power_loop-0.2.0/power_loop/contracts/__init__.py +132 -0
- power_loop-0.2.0/power_loop/contracts/errors.py +140 -0
- power_loop-0.2.0/power_loop/contracts/event_payloads.py +278 -0
- power_loop-0.2.0/power_loop/contracts/events.py +86 -0
- power_loop-0.2.0/power_loop/contracts/handlers.py +45 -0
- power_loop-0.2.0/power_loop/contracts/hook_contexts.py +265 -0
- power_loop-0.2.0/power_loop/contracts/hooks.py +64 -0
- power_loop-0.2.0/power_loop/contracts/messages.py +90 -0
- power_loop-0.2.0/power_loop/contracts/protocols.py +48 -0
- power_loop-0.2.0/power_loop/contracts/tools.py +56 -0
- power_loop-0.2.0/power_loop/core/agent_context.py +94 -0
- power_loop-0.2.0/power_loop/core/events.py +124 -0
- power_loop-0.2.0/power_loop/core/hooks.py +122 -0
- power_loop-0.2.0/power_loop/core/phase.py +217 -0
- power_loop-0.2.0/power_loop/core/pipeline.py +880 -0
- power_loop-0.2.0/power_loop/core/runner.py +60 -0
- power_loop-0.2.0/power_loop/core/state.py +208 -0
- power_loop-0.2.0/power_loop/runtime/budget.py +179 -0
- power_loop-0.2.0/power_loop/runtime/cancellation.py +127 -0
- power_loop-0.2.0/power_loop/runtime/compact.py +300 -0
- power_loop-0.2.0/power_loop/runtime/env.py +103 -0
- power_loop-0.2.0/power_loop/runtime/memory.py +107 -0
- power_loop-0.2.0/power_loop/runtime/provider.py +176 -0
- power_loop-0.2.0/power_loop/runtime/retry.py +182 -0
- power_loop-0.2.0/power_loop/runtime/session_store.py +636 -0
- power_loop-0.2.0/power_loop/runtime/skills.py +201 -0
- power_loop-0.2.0/power_loop/runtime/spec.py +233 -0
- power_loop-0.2.0/power_loop/runtime/structured.py +225 -0
- power_loop-0.2.0/power_loop/tools/__init__.py +51 -0
- power_loop-0.2.0/power_loop/tools/default_manifest.py +244 -0
- power_loop-0.2.0/power_loop/tools/default_tools.py +766 -0
- power_loop-0.2.0/power_loop/tools/registry.py +162 -0
- power_loop-0.2.0/power_loop/tools/spawn_agent.py +173 -0
- power_loop-0.2.0/power_loop.egg-info/PKG-INFO +632 -0
- power_loop-0.2.0/power_loop.egg-info/SOURCES.txt +56 -0
- power_loop-0.2.0/power_loop.egg-info/dependency_links.txt +1 -0
- power_loop-0.2.0/power_loop.egg-info/requires.txt +13 -0
- power_loop-0.2.0/power_loop.egg-info/top_level.txt +2 -0
- power_loop-0.2.0/pyproject.toml +97 -0
- power_loop-0.2.0/setup.cfg +4 -0
power_loop-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 zhangran
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: power-loop
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Embeddable agent execution kernel — LLM loop, hooks, events, tools, dynamic sub-agents.
|
|
5
|
+
Author-email: zhangran <zhangran24@126.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/deep-talk0/power-loop
|
|
8
|
+
Project-URL: Repository, https://github.com/deep-talk0/power-loop
|
|
9
|
+
Project-URL: Changelog, https://github.com/deep-talk0/power-loop/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Roadmap, https://github.com/deep-talk0/power-loop/blob/main/ROADMAP.md
|
|
11
|
+
Keywords: agent,llm,openai,anthropic,tool-use,hooks
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: anthropic>=0.42.0
|
|
25
|
+
Requires-Dist: openai>=1.52.0
|
|
26
|
+
Requires-Dist: socksio>=1.0.0
|
|
27
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
28
|
+
Requires-Dist: pyyaml>=6.0
|
|
29
|
+
Requires-Dist: rich>=13.0.0
|
|
30
|
+
Requires-Dist: pypdf>=5.3.0
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
34
|
+
Requires-Dist: ruff>=0.6.0; extra == "dev"
|
|
35
|
+
Requires-Dist: mypy>=1.10.0; extra == "dev"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# power-loop
|
|
39
|
+
|
|
40
|
+
[English Documentation](docs/en/index.md) | [中文文档](docs/zh/index.md)
|
|
41
|
+
|
|
42
|
+
> **可嵌入的、有状态的 Agent 执行内核。** 调用方只管「给一段最新输入 + session_id」,
|
|
43
|
+
> power-loop 自治管理:LLM 多轮循环、工具调用、上下文压缩、子代理、消息持久化(SQLite)、
|
|
44
|
+
> 悬挂态恢复。
|
|
45
|
+
>
|
|
46
|
+
> Python ≥ 3.10 · MIT · OpenAI 兼容 + Anthropic 双家底 · 零强依赖(SQLite 用 stdlib)
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from power_loop import StatefulAgentLoop, AgentLoopConfig
|
|
50
|
+
|
|
51
|
+
loop = StatefulAgentLoop(
|
|
52
|
+
llm=my_llm,
|
|
53
|
+
db_path="./sessions.db",
|
|
54
|
+
config=AgentLoopConfig(system_prompt="You are helpful.", max_rounds=8),
|
|
55
|
+
)
|
|
56
|
+
r1 = await loop.send("hello") # 自动新建 session
|
|
57
|
+
r2 = await loop.send("more please", session_id=r1.session_id) # 续话
|
|
58
|
+
loop.close_session(r1.session_id) # 物理删除
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 目录
|
|
64
|
+
|
|
65
|
+
- [1. 它是什么 / 不是什么](#1-它是什么--不是什么)
|
|
66
|
+
- [2. 安装](#2-安装)
|
|
67
|
+
- [3. Quickstart](#3-quickstart)
|
|
68
|
+
- [4. 核心概念](#4-核心概念)
|
|
69
|
+
- [5. API 参考](#5-api-参考)
|
|
70
|
+
- [6. Examples](#6-examples)
|
|
71
|
+
- [7. 配置(环境变量)](#7-配置环境变量)
|
|
72
|
+
- [8. 内部机制](#8-内部机制)
|
|
73
|
+
- [9. 测试](#9-测试)
|
|
74
|
+
- [10. Roadmap & Changelog](#10-roadmap--changelog)
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## 1. 它是什么 / 不是什么
|
|
79
|
+
|
|
80
|
+
**是**:
|
|
81
|
+
- 一个 Python 库(非服务),可被任意后端 / CLI / 测试 `import` 使用。
|
|
82
|
+
- **唯一公开入口** `StatefulAgentLoop`:有状态、`send/resume/abort_pending/close_session` 四个动词。
|
|
83
|
+
- 持久化层 `SessionStore`:SQLite,5 张表,承诺消息顺序、悬挂态、压缩审计的所有不变量。
|
|
84
|
+
- 工具循环 + 生命周期 Hook + 事件总线 + 子代理(命令式 + 声明式 `AgentSpec`)。
|
|
85
|
+
- 上下文压缩:可插拔 `Compactor` 协议,自带 `DefaultCompactor` 默认开启。
|
|
86
|
+
|
|
87
|
+
**不是**:
|
|
88
|
+
- 不是 IM / 业务服务。不感知会话、用户、Kafka、HTTP;这些放在调用方。
|
|
89
|
+
- 不是大而全的 Agent Framework。没有内置 RAG / 向量库 / Planner / DAG。
|
|
90
|
+
- 不绑死任何模型厂商:`base_url` / `api_key` / `model` 都通过配置传入。
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 2. 安装
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pip install -e . # 开发安装
|
|
98
|
+
pip install -e ".[dev]" # 含 pytest / ruff / mypy
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
依赖(见 `pyproject.toml`):`openai`、`anthropic`、`socksio`、`python-dotenv`、`pyyaml`、`rich`、`pypdf`。**SQLite 用 Python 标准库**,零新增。
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## 3. Quickstart
|
|
106
|
+
|
|
107
|
+
本节按由浅入深的顺序排:每一步只引入一个新概念。每个代码片段都对应 `examples/`
|
|
108
|
+
下一个可独立运行的文件。先按顺序读完,再回头看 §4 的核心概念会很顺。
|
|
109
|
+
|
|
110
|
+
### 3.1 第一次发送(最小用法)
|
|
111
|
+
|
|
112
|
+
最少的样板:构造 LLM → 构造 loop → `send`。
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
import asyncio
|
|
116
|
+
from power_loop import StatefulAgentLoop
|
|
117
|
+
|
|
118
|
+
async def main():
|
|
119
|
+
loop = StatefulAgentLoop(llm=my_llm, db_path=":memory:")
|
|
120
|
+
result = await loop.send("In one sentence: what is HTTP?")
|
|
121
|
+
print(result.final_text)
|
|
122
|
+
|
|
123
|
+
asyncio.run(main())
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
要点:
|
|
127
|
+
- `db_path=":memory:"` 是临时 store;生产换成文件路径就能跨进程保留。
|
|
128
|
+
- 不传 `session_id` → 自动创建新 session。返回的 `result.session_id` 是后续续话的钥匙。
|
|
129
|
+
- 没传 `AgentLoopConfig` 也行:全部用默认值(`max_rounds=24`、`DefaultCompactor()` 等)。
|
|
130
|
+
|
|
131
|
+
→ 完整版:[`examples/00_minimal.py`](examples/00_minimal.py)
|
|
132
|
+
|
|
133
|
+
### 3.2 多轮对话
|
|
134
|
+
|
|
135
|
+
唯一新东西:把上一轮返回的 `session_id` 传回去。
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
r1 = await loop.send("My favorite color is teal.")
|
|
139
|
+
r2 = await loop.send("What did I just say?", session_id=r1.session_id)
|
|
140
|
+
# r2.final_text 会引用 "teal"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
power-loop 会自动从 store 加载历史,模型每轮看到的都是完整上下文。你不用维护
|
|
144
|
+
"messages list",只管最新的一句输入。
|
|
145
|
+
|
|
146
|
+
→ 完整版:[`examples/01_multi_turn.py`](examples/01_multi_turn.py)
|
|
147
|
+
|
|
148
|
+
### 3.3 工具调用
|
|
149
|
+
|
|
150
|
+
要让模型调用你的 Python 函数:写 `ToolDefinition` + handler,注册到 `ToolRegistry`,
|
|
151
|
+
传给 loop。
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from power_loop import ToolDefinition, ToolRegistry, AgentLoopConfig
|
|
155
|
+
|
|
156
|
+
def lookup_dish(**kwargs) -> str:
|
|
157
|
+
return {"lima": "ceviche", "tokyo": "sushi"}.get(kwargs["city"].lower(), "?")
|
|
158
|
+
|
|
159
|
+
registry = ToolRegistry()
|
|
160
|
+
registry.register(
|
|
161
|
+
ToolDefinition(
|
|
162
|
+
name="lookup_dish",
|
|
163
|
+
description="Return the local dish for a city.",
|
|
164
|
+
input_schema={"type": "object",
|
|
165
|
+
"properties": {"city": {"type": "string"}},
|
|
166
|
+
"required": ["city"]},
|
|
167
|
+
required_params=("city",),
|
|
168
|
+
),
|
|
169
|
+
lookup_dish,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
loop = StatefulAgentLoop(
|
|
173
|
+
llm=my_llm, db_path=":memory:", tool_registry=registry,
|
|
174
|
+
config=AgentLoopConfig(max_rounds=4),
|
|
175
|
+
)
|
|
176
|
+
result = await loop.send("What's Lima's signature dish?")
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
为什么 `max_rounds ≥ 2`:工具调用本质是两步——第 1 轮 LLM 决定调工具,第 2 轮看
|
|
180
|
+
到工具结果给最终答案。`max_rounds=1` 跑不通工具。
|
|
181
|
+
|
|
182
|
+
→ 完整版:[`examples/02_tool_use.py`](examples/02_tool_use.py)
|
|
183
|
+
|
|
184
|
+
### 3.4 子代理
|
|
185
|
+
|
|
186
|
+
`register_spawn_agent(registry)` 一行注入两个 meta-tool:`spawn_agent`(命令式)
|
|
187
|
+
和 `run_agent`(声明式 `AgentSpec`)。父 LLM 自主决定调用,子会话在同一个
|
|
188
|
+
`SessionStore` 里独立跑完,把 `final_text` 当 tool 结果回灌给父。
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
from power_loop import register_spawn_agent
|
|
192
|
+
|
|
193
|
+
registry = ToolRegistry()
|
|
194
|
+
register_spawn_agent(registry)
|
|
195
|
+
loop = StatefulAgentLoop(
|
|
196
|
+
llm=my_llm, db_path=":memory:", tool_registry=registry,
|
|
197
|
+
config=AgentLoopConfig(
|
|
198
|
+
system_prompt="Delegate factual Qs via spawn_agent.",
|
|
199
|
+
max_rounds=5,
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
result = await loop.send("Delegate this: capital of Japan?")
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
→ 完整版:[`examples/03_subagent.py`](examples/03_subagent.py)
|
|
206
|
+
|
|
207
|
+
### 3.5 上下文压缩
|
|
208
|
+
|
|
209
|
+
历史变长后默认压缩自动触发:被折叠的消息在 store 里标 `compacted_out`,
|
|
210
|
+
插入一条 `compact_note` 摘要替代。开关、阈值、保留条数都在 `DefaultCompactor`
|
|
211
|
+
构造参数里。完整版演示了如何检查 `store.list_compactions(sid)` 看审计行。
|
|
212
|
+
|
|
213
|
+
→ [`examples/04_compaction.py`](examples/04_compaction.py)
|
|
214
|
+
|
|
215
|
+
### 3.6 悬挂态恢复
|
|
216
|
+
|
|
217
|
+
如果进程在 `assistant(tool_calls)` 已落库、`tool` 消息还没全部落库时挂掉,session
|
|
218
|
+
处于悬挂态。下次 `send` 会抛 `SessionPendingError`,由调用方选 `resume` 还是
|
|
219
|
+
`abort_pending`。
|
|
220
|
+
|
|
221
|
+
→ [`examples/05_pending_resume.py`](examples/05_pending_resume.py)
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## 4. 核心概念
|
|
226
|
+
|
|
227
|
+
### Session
|
|
228
|
+
|
|
229
|
+
一次 send/resume 循环跑在一个 **session** 上。session 是 `SessionStore` 里 sessions 表的一行,承载 system prompt、AgentLoopConfig 快照、metadata 与所有 messages。`send(user_input)` 不传 `session_id` 自动新建;传则续话。
|
|
230
|
+
|
|
231
|
+
### SessionStore
|
|
232
|
+
|
|
233
|
+
power-loop 的**唯一**持久化入口(SQLite)。5 张表:
|
|
234
|
+
|
|
235
|
+
| 表 | 作用 |
|
|
236
|
+
|---|---|
|
|
237
|
+
| `sessions` | 元数据 + 父子链接 + 生命周期 |
|
|
238
|
+
| `messages` | 每条消息 + `state ∈ {active, compacted_out}` + seq |
|
|
239
|
+
| `compactions` | 每次压缩的审计行(覆盖了哪些 seq → 哪个 note) |
|
|
240
|
+
| `usage_rounds` | 每轮 token 用量 |
|
|
241
|
+
| `session_state` | next_seq / round_index / pending |
|
|
242
|
+
|
|
243
|
+
并发:单连接 + `threading.RLock`;SQLite WAL 模式,允许多读者跨进程共享文件。
|
|
244
|
+
|
|
245
|
+
### Sink
|
|
246
|
+
|
|
247
|
+
`MessageSink` 是 pipeline 与持久化之间的协议。`StatefulAgentLoop` 默认装 `SQLiteSink`,把每条消息、压缩、usage 持久化到 store。NullSink 是测试用的 no-op。
|
|
248
|
+
|
|
249
|
+
### Compactor
|
|
250
|
+
|
|
251
|
+
可插拔的上下文压缩策略。`AgentLoopConfig.compactor` 默认为 `DefaultCompactor()`;传 `None` 关闭。
|
|
252
|
+
|
|
253
|
+
**触发**:`estimate_tokens(history) ≥ max_tokens × trigger_ratio`(默认 0.75);
|
|
254
|
+
环境变量 `CONTEXT_COMPACT_THRESHOLD` 可设绝对值覆盖。
|
|
255
|
+
|
|
256
|
+
**不变量**(DefaultCompactor 实现,自定义 Compactor 应遵守):
|
|
257
|
+
1. 保留所有 `role=system` 消息(含先前 `compact_note`);
|
|
258
|
+
2. 保留尾部 `keep_last_n` 个 user 段(默认 4);
|
|
259
|
+
3. **绝不切开 `assistant(tool_calls) ↔ tool(tool_call_id=…)` 原子对**;
|
|
260
|
+
4. 摘要 LLM 抛错 → 返回 `None` → 主循环用未压缩 history 继续(软降级)。
|
|
261
|
+
|
|
262
|
+
### Pending 状态机
|
|
263
|
+
|
|
264
|
+
一轮工具调用的协议是:`assistant(tool_calls=[A,B])` → `tool(tool_call_id=A)` + `tool(tool_call_id=B)`。
|
|
265
|
+
|
|
266
|
+
如果进程在 assistant 已落库、tool 还没全部落库时挂掉,session 处于**悬挂态**。下次 `send` 会抛 `SessionPendingError`。调用方两个选项:
|
|
267
|
+
- `await loop.resume(sid)` — 把剩余 tool_calls 跑完,继续循环;
|
|
268
|
+
- `loop.abort_pending(sid, reason="…")` — 给每个未完成 tool_call 写一条 `<aborted: reason>` tool 消息,恢复协议合法性,再 `send` 即可继续。
|
|
269
|
+
|
|
270
|
+
### 子代理
|
|
271
|
+
|
|
272
|
+
- `spawn_agent(task, ...)` — 命令式 meta-tool,LLM 用 kwargs 调用,自动包成 `AgentSpec`。
|
|
273
|
+
- `run_agent(spec, input)` — 声明式 meta-tool,LLM 提交完整 spec(严格 schema,未知字段拒绝)。
|
|
274
|
+
|
|
275
|
+
两者都走同一份内部实现 `run_agent_spec`,差异只在入口形态。子会话与父共享同一个 `SessionStore`,建立 `parent_session_id` / `spawn_tool_call_id` 链接,`spawn_depth ≤ 3` 强校验。
|
|
276
|
+
|
|
277
|
+
**生命周期** `SubagentLifecycle`:
|
|
278
|
+
- `EPHEMERAL`(默认):子 session 完成时物理删除;非完成态(hit_round_limit / cancelled)保留供 debug;
|
|
279
|
+
- `LINKED`:保留;父 `close_session(cascade=True)` 时随之级联删;
|
|
280
|
+
- `DETACHED`:保留;父 close 时不影响(解链)。
|
|
281
|
+
|
|
282
|
+
### Hooks & Events
|
|
283
|
+
|
|
284
|
+
两条互不污染的通道:
|
|
285
|
+
|
|
286
|
+
- **Hooks**(控制流):15 个 `HookPoint`,每个 hook 返回 `HookDirective ∈ {CONTINUE/SKIP/BREAK/SHORT_CIRCUIT}`。改 LLM 请求、注入 mock 结果、安全门、提前终止都靠它。完整 hook 表与示例:[`docs/hooks.md`](docs/hooks.md)。
|
|
287
|
+
- **Events**(旁路只读):`AgentEventBus` 发布 token usage / stream delta / tool call started / completed / 等。指标、审计、UI 推送都订阅它。完整 event 表与示例:[`docs/events.md`](docs/events.md)。
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## 5. API 参考
|
|
292
|
+
|
|
293
|
+
### `StatefulAgentLoop`
|
|
294
|
+
|
|
295
|
+
唯一公开入口。一个实例可并发驱动多个 session(每 session 一把 `asyncio.Lock`)。
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
StatefulAgentLoop(
|
|
299
|
+
*,
|
|
300
|
+
llm: LLMService,
|
|
301
|
+
store: SessionStore | None = None, # 传 None → 用 db_path 自建
|
|
302
|
+
db_path: str = "./power_loop_sessions.db",
|
|
303
|
+
config: AgentLoopConfig | None = None,
|
|
304
|
+
tool_registry: ToolRegistry | None = None,
|
|
305
|
+
hooks: AgentHooks | None = None,
|
|
306
|
+
event_bus: AgentEventBus | None = None,
|
|
307
|
+
)
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
| 方法 | 说明 |
|
|
311
|
+
|---|---|
|
|
312
|
+
| `await send(user_input, session_id=None, *, metadata=None, stop_event=None) -> StatefulResult` | 主入口。无 `session_id` 自动新建;悬挂态 → `SessionPendingError`。 |
|
|
313
|
+
| `send_sync(...)` | 同上的同步壳。 |
|
|
314
|
+
| `await resume(session_id)` | 把悬挂的 tool_calls 跑完,继续循环。 |
|
|
315
|
+
| `abort_pending(session_id, *, reason="aborted") -> int` | 给悬挂 tool_calls 写 `<aborted>` tool 消息,返回 abort 数量。 |
|
|
316
|
+
| `close_session(session_id, *, cascade=True) -> int` | 物理删除 session(含 LINKED 子树)。 |
|
|
317
|
+
| `close()` | 关 store(若 owned)。不删数据。 |
|
|
318
|
+
| `get_messages(session_id, *, include_compacted=False) -> list[dict]` | 取当前 active history。 |
|
|
319
|
+
| `get_pending(session_id) -> dict \| None` | 查悬挂态。 |
|
|
320
|
+
|
|
321
|
+
### `StatefulResult`
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
@dataclass
|
|
325
|
+
class StatefulResult:
|
|
326
|
+
session_id: str
|
|
327
|
+
status: str # "completed" / "hit_round_limit" / "cancelled" / "pending_tools"
|
|
328
|
+
final_text: str = ""
|
|
329
|
+
rounds: int = 0
|
|
330
|
+
pending_tool_calls: list[dict] = []
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### `AgentLoopConfig`
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
@dataclass
|
|
337
|
+
class AgentLoopConfig:
|
|
338
|
+
system_prompt: str | None = None
|
|
339
|
+
max_rounds: int = 24
|
|
340
|
+
temperature: float | None = 0.0
|
|
341
|
+
max_tokens: int | None = 8000
|
|
342
|
+
compactor: Compactor | None = DefaultCompactor() # 传 None 关闭压缩
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### `SessionStore`
|
|
346
|
+
|
|
347
|
+
```python
|
|
348
|
+
SessionStore.open(path="./power_loop_sessions.db") -> SessionStore
|
|
349
|
+
store.close()
|
|
350
|
+
store.create_session(*, system_prompt=None, model=None, config=None,
|
|
351
|
+
parent_session_id=None, spawn_tool_call_id=None,
|
|
352
|
+
kind=SessionKind.ROOT,
|
|
353
|
+
lifecycle=SubagentLifecycle.EPHEMERAL,
|
|
354
|
+
metadata=None, session_id=None) -> str
|
|
355
|
+
store.get_session(sid) -> SessionRow | None
|
|
356
|
+
store.list_children(parent_sid) -> list[SessionRow]
|
|
357
|
+
store.close_session(sid, *, cascade=True) -> int # 物理删除,返回行数
|
|
358
|
+
store.archive_session(sid) # 改 status,不删
|
|
359
|
+
store.append_message(sid, *, role, content=None, tool_calls=None,
|
|
360
|
+
tool_call_id=None, name=None, round_index=None,
|
|
361
|
+
meta=None) -> int # 返回新 seq
|
|
362
|
+
store.load_active_messages(sid) -> list[MessageRow]
|
|
363
|
+
store.load_all_messages(sid) -> list[MessageRow] # 含 compacted_out
|
|
364
|
+
store.record_compaction(sid, *, from_seq, to_seq, note_content,
|
|
365
|
+
before_tokens, after_tokens, round_index) -> tuple[int, int]
|
|
366
|
+
store.list_compactions(sid) -> list[CompactionRow]
|
|
367
|
+
store.record_usage(sid, *, round_index, prompt_tokens,
|
|
368
|
+
completion_tokens, total_tokens, model=None)
|
|
369
|
+
store.get_state(sid) -> SessionStateRow | None
|
|
370
|
+
store.set_pending(sid, pending: dict | None)
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### 子代理:`AgentSpec` + 工具
|
|
374
|
+
|
|
375
|
+
```python
|
|
376
|
+
@dataclass(frozen=True)
|
|
377
|
+
class AgentSpec:
|
|
378
|
+
name: str
|
|
379
|
+
system_prompt: str
|
|
380
|
+
tools: list[str] | None = None # parent tool whitelist, None=inherit all
|
|
381
|
+
max_rounds: int = 8 # 1..50
|
|
382
|
+
max_tokens: int = 4000
|
|
383
|
+
temperature: float = 0.0
|
|
384
|
+
model: str | None = None
|
|
385
|
+
lifecycle: str = "ephemeral" # "ephemeral" / "linked" / "detached"
|
|
386
|
+
metadata: dict[str, Any] = {}
|
|
387
|
+
# 工厂:AgentSpec.from_dict(d) / AgentSpec.from_json(s)
|
|
388
|
+
# 严格 schema:未知字段 / 非法 lifecycle / max_rounds 越界 → AgentSpecError
|
|
389
|
+
|
|
390
|
+
from power_loop import register_spawn_agent
|
|
391
|
+
register_spawn_agent(registry, *, include_run_agent=True, overwrite=False)
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
直接 API(绕过 meta-tool):
|
|
395
|
+
|
|
396
|
+
```python
|
|
397
|
+
from power_loop import run_agent_spec
|
|
398
|
+
result = await run_agent_spec(spec, "user input", parent_loop=loop)
|
|
399
|
+
# → {"session_id", "status", "final_text", "rounds", "depth"}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### Errors
|
|
403
|
+
|
|
404
|
+
```python
|
|
405
|
+
class PowerLoopError(Exception): ...
|
|
406
|
+
class SessionNotFoundError(PowerLoopError):
|
|
407
|
+
session_id: str
|
|
408
|
+
class SessionPendingError(PowerLoopError):
|
|
409
|
+
session_id: str
|
|
410
|
+
assistant_seq: int
|
|
411
|
+
pending_tool_calls: list[dict]
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### Compactor
|
|
415
|
+
|
|
416
|
+
```python
|
|
417
|
+
@dataclass(frozen=True)
|
|
418
|
+
class CompactionPlan:
|
|
419
|
+
fold_start_idx: int # inclusive
|
|
420
|
+
fold_end_idx: int # inclusive
|
|
421
|
+
summary_text: str
|
|
422
|
+
before_tokens: int
|
|
423
|
+
after_tokens: int
|
|
424
|
+
|
|
425
|
+
class Compactor(Protocol):
|
|
426
|
+
async def maybe_compact(self, messages, *, llm, max_tokens, round_index) -> CompactionPlan | None: ...
|
|
427
|
+
|
|
428
|
+
class DefaultCompactor:
|
|
429
|
+
def __init__(self, *, trigger_ratio=0.75, keep_last_n=4,
|
|
430
|
+
summary_max_tokens=512, summary_llm=None, absolute_threshold=None): ...
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
自定义 compactor 实现上面的 Protocol 即可注入 `AgentLoopConfig.compactor=YourCompactor()`。
|
|
434
|
+
|
|
435
|
+
### Public API 稳定性约定
|
|
436
|
+
|
|
437
|
+
power-loop 采用 **三层分级**,与 `power_loop/__init__.py` 的 `STABLE_API` 元组同步:
|
|
438
|
+
|
|
439
|
+
#### STABLE(跨 minor 保证向后兼容)
|
|
440
|
+
|
|
441
|
+
破坏性变更必须升 minor 版本号(0.x → 0.x+1)+ CHANGELOG 独立条目。**业务方只应依赖这些符号。**
|
|
442
|
+
|
|
443
|
+
| 符号 | 一句话 |
|
|
444
|
+
|---|---|
|
|
445
|
+
| `StatefulAgentLoop` | 主入口:`send()` / `resume()` / `abort_pending()` |
|
|
446
|
+
| `StatefulResult` | `send()` 返回值:`session_id` / `status` / `final_text` / `rounds` |
|
|
447
|
+
| `AgentLoopConfig` | 配置单:`system_prompt` / `max_rounds` / `compactor` / `retry_policy` / `memory` / … |
|
|
448
|
+
| `AgentLoopResult` | Pipeline 内部返回值(`status` / `final_text` / `rounds` / `messages`) |
|
|
449
|
+
| `SessionStore` | SQLite 持久化:`open(path)` / `create_session()` / `append_message()` / … |
|
|
450
|
+
| `SubagentLifecycle` | Enum:`EPHEMERAL` / `LINKED` / `DETACHED` |
|
|
451
|
+
| `PowerLoopError` | 所有异常的基类,`except PowerLoopError` 一把抓 |
|
|
452
|
+
| `SessionNotFoundError` | `session_id` 不在 store 里 |
|
|
453
|
+
| `SessionPendingError` | 上次崩溃留下未完成的 `tool_calls` |
|
|
454
|
+
| `LLMTimeout` | LLM 调用(或一系列 retry)超 `total_timeout` |
|
|
455
|
+
| `LLMRetryExhausted` | `max_attempts` 次重试仍未成功 |
|
|
456
|
+
| `CancellationRequested` | `CancellationToken` 已 flip |
|
|
457
|
+
| `ToolNotFound` | 调用了一个未注册的 tool 名字 |
|
|
458
|
+
| `ToolValidationError` | tool args 未通过 schema / required 校验 |
|
|
459
|
+
| `SpecValidationError` | `AgentSpec` 严格 schema 拒绝(`AgentSpecError` 的父类) |
|
|
460
|
+
| `LLMRetryPolicy` | 重试策略:`max_attempts` / `backoff_*` / `total_timeout` / `retry_on` |
|
|
461
|
+
| `CancellationToken` | 统一 cancel 形状:`from_any(ev)` / `cancel(reason)` |
|
|
462
|
+
| `AgentHooks` | Hook 管理器:`register(pt, fn)` / `register_async(pt, async_fn)` |
|
|
463
|
+
| `AgentEventBus` | 事件总线:`subscribe(type, fn)` / `publish(event)` |
|
|
464
|
+
| `HookPoint` | Enum:`SESSION_START` … `MEMORY_RECALLED`(18 个) |
|
|
465
|
+
| `HookDirective` | Enum:`CONTINUE` / `SKIP` / `BREAK` / `SHORT_CIRCUIT` |
|
|
466
|
+
| `ToolRegistry` | 工具注册表:`register(def, handler)` / `invoke_async(name, args)` |
|
|
467
|
+
| `ToolDefinition` | 工具声明:`name` / `description` / `input_schema` / `required_params` |
|
|
468
|
+
|
|
469
|
+
#### PROVISIONAL(0.x 阶段可能调整)
|
|
470
|
+
|
|
471
|
+
从 `power_loop` 顶层导入,但**不在 STABLE 列表中**。生产代码引用前确认版本号。
|
|
472
|
+
|
|
473
|
+
例:`MessageSink` / `SQLiteSink` / `AgentSpec` / `run_agent_spec` / `MemoryProvider` / `MemorySnapshot` / `StructuredOutputSpec` / `parse_structured` / `trim_history` / `LLMProviderConfig` / 全部 `*Payload` / 全部 `*Ctx` 等。
|
|
474
|
+
|
|
475
|
+
#### INTERNAL(无版本承诺)
|
|
476
|
+
|
|
477
|
+
从 `power_loop.core.*` / `power_loop.runtime.*` 等子模块导入的符号视为 internal,可随时变更或删除。Pipeline / Runner / ContextManager 等都在这一层。
|
|
478
|
+
|
|
479
|
+
---
|
|
480
|
+
|
|
481
|
+
## 6. Examples
|
|
482
|
+
|
|
483
|
+
`examples/` 下每个文件可独立 `python examples/NN_*.py` 运行,并由 `tests/real/test_examples.py` 持续验证。
|
|
484
|
+
|
|
485
|
+
推荐按编号顺序读:每个文件只引入一个新概念。
|
|
486
|
+
|
|
487
|
+
| 文件 | 你会学到 |
|
|
488
|
+
|---|---|
|
|
489
|
+
| [`00_minimal.py`](examples/00_minimal.py) | 最小用法:`StatefulAgentLoop(llm=…).send(text)` |
|
|
490
|
+
| [`01_multi_turn.py`](examples/01_multi_turn.py) | 用 `session_id` 续话 + `get_messages` / `close_session` |
|
|
491
|
+
| [`02_tool_use.py`](examples/02_tool_use.py) | 自定义 `ToolDefinition` + 多轮工具调用 |
|
|
492
|
+
| [`03_subagent.py`](examples/03_subagent.py) | `spawn_agent` meta-tool + EPHEMERAL 自动清理 |
|
|
493
|
+
| [`04_compaction.py`](examples/04_compaction.py) | `DefaultCompactor` 自动折叠 + 查看 store 审计行 |
|
|
494
|
+
| [`05_pending_resume.py`](examples/05_pending_resume.py) | `SessionPendingError` + `resume` / `abort_pending` |
|
|
495
|
+
| [`06_declarative_subagent.py`](examples/06_declarative_subagent.py) | `AgentSpec` 严格 schema + `run_agent` meta-tool + 直接调 `run_agent_spec` |
|
|
496
|
+
| [`07_user_confirmation.py`](examples/07_user_confirmation.py) | 用 async `TOOL_BEFORE` hook 实现「执行前问用户」中断 |
|
|
497
|
+
| [`08_streaming.py`](examples/08_streaming.py) | 订阅 `STREAM_DELTA` event 做打字机渲染 |
|
|
498
|
+
| [`09_audit_log.py`](examples/09_audit_log.py) | `bus.subscribe(None, …)` 全量审计写 JSONL |
|
|
499
|
+
| [`10_async_approval_queue.py`](examples/10_async_approval_queue.py) | 多并发 session + asyncio.Queue 审批 worker |
|
|
500
|
+
| [`11_persistence.py`](examples/11_persistence.py) | `db_path` 跨进程恢复:子进程拿同一个 SQLite 文件续上 |
|
|
501
|
+
| [`12_retry_and_cancel.py`](examples/12_retry_and_cancel.py) | `LLMRetryPolicy` + 注入失败 → 重试 / degraded / cancel 三条路径 |
|
|
502
|
+
| [`13_memory_sqlite.py`](examples/13_memory_sqlite.py) | `MemoryProvider` 跨 session SQLite 事实记忆 |
|
|
503
|
+
| [`14_structured_card.py`](examples/14_structured_card.py) | `StructuredOutputSpec` + `parse_structured` 抽取 JSON 卡片 |
|
|
504
|
+
|
|
505
|
+
`examples/_helpers.py` 是共享的 `.env` 读取 + LLM 构造辅助,每个示例 `from _helpers import make_llm`,省掉 boilerplate。复制到自己项目时把那两行内联即可。
|
|
506
|
+
|
|
507
|
+
---
|
|
508
|
+
|
|
509
|
+
## 7. 配置(环境变量)
|
|
510
|
+
|
|
511
|
+
LLM 凭证与端点 **不入代码**,统一走环境变量(建议 `.env` + `python-dotenv`):
|
|
512
|
+
|
|
513
|
+
**推荐**(`POWER_LOOP_*`,M1.4 起):
|
|
514
|
+
|
|
515
|
+
| 变量 | 说明 |
|
|
516
|
+
|---|---|
|
|
517
|
+
| `POWER_LOOP_BASE_URL` | OpenAI 兼容端点 |
|
|
518
|
+
| `POWER_LOOP_API_KEY` | API key |
|
|
519
|
+
| `POWER_LOOP_MODEL` | 默认模型名 |
|
|
520
|
+
| `POWER_LOOP_PROVIDER` | 标签(openai / dashscope / deepseek / …) |
|
|
521
|
+
|
|
522
|
+
向后兼容 `OPENAI_COMPAT_*`(旧 `.env` 不改名继续工作)。详见 [`docs/providers.md`](docs/providers.md)。
|
|
523
|
+
|
|
524
|
+
构造 LLM 一行:
|
|
525
|
+
|
|
526
|
+
```python
|
|
527
|
+
from power_loop import create_llm_service_from_env
|
|
528
|
+
llm = create_llm_service_from_env() # 读 POWER_LOOP_*(回退到 OPENAI_COMPAT_*)
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
旧方式(仍可用):
|
|
532
|
+
|
|
533
|
+
```python
|
|
534
|
+
from llm_client.interface import OpenAICompatibleChatConfig
|
|
535
|
+
from llm_client.llm_factory import OpenAICompatibleChatLLMService
|
|
536
|
+
import os
|
|
537
|
+
|
|
538
|
+
cfg = OpenAICompatibleChatConfig(
|
|
539
|
+
base_url=os.environ["OPENAI_COMPAT_BASE_URL"],
|
|
540
|
+
api_key=os.environ["OPENAI_COMPAT_API_KEY"],
|
|
541
|
+
model=os.environ["OPENAI_COMPAT_MODEL"],
|
|
542
|
+
max_tokens=512, temperature=0.2,
|
|
543
|
+
)
|
|
544
|
+
llm = OpenAICompatibleChatLLMService(cfg)
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
---
|
|
548
|
+
|
|
549
|
+
## 8. 内部机制
|
|
550
|
+
|
|
551
|
+
完整版(含 Mermaid 架构图、序列图、状态机):[`docs/architecture.md`](docs/architecture.md)。
|
|
552
|
+
本节是浓缩。
|
|
553
|
+
|
|
554
|
+
### Pipeline 一回合
|
|
555
|
+
|
|
556
|
+
```
|
|
557
|
+
session.start
|
|
558
|
+
↓
|
|
559
|
+
for round in 0..max_rounds:
|
|
560
|
+
├ round.start → sink.on_round_started
|
|
561
|
+
├ prepare_round
|
|
562
|
+
│ ├ todo reminder(每 5 轮)
|
|
563
|
+
│ ├ microcompact(大 tool 输出溢盘到 .cache/)
|
|
564
|
+
│ └ compactor.maybe_compact → 命中则 sink.on_compaction → 持久化
|
|
565
|
+
├ llm.before → LLM.complete → llm.after
|
|
566
|
+
├ assistant 消息落 sink(带 tool_calls 时立即 set_pending)
|
|
567
|
+
├ 若无 tool_calls → round.end → 返回 "completed"
|
|
568
|
+
├ round.decide
|
|
569
|
+
├ tools.batch.before
|
|
570
|
+
│ ├ tool.before → tool.invoke → tool.after / tool.error
|
|
571
|
+
│ └ tool 消息落 sink(同 tool_call_id 解 pending)
|
|
572
|
+
├ tools.batch.after
|
|
573
|
+
└ round.end → sink.on_round_ended(usage=…)
|
|
574
|
+
session.end
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
15 个 HookPoint:见 `power_loop/contracts/hooks.py`。
|
|
578
|
+
|
|
579
|
+
### 消息持久化与 seq
|
|
580
|
+
|
|
581
|
+
每条消息在 store 里有唯一 `(session_id, seq)`。`SQLiteSink` 在内存里维护一份 `_history_seqs` 与 pipeline.history 一一对应:
|
|
582
|
+
- 加载老 session:`init_history_seqs([row.seq for row in active_rows])`
|
|
583
|
+
- 新追加:`store.append_message` 返回 seq,追加到尾
|
|
584
|
+
- 压缩:`on_compaction(fold_start_idx, fold_end_idx, …)` → 用索引转 seq → `store.record_compaction` → 重写 `_history_seqs`(折叠区间替换为 note 的 seq)
|
|
585
|
+
|
|
586
|
+
### Pending 状态机
|
|
587
|
+
|
|
588
|
+
```
|
|
589
|
+
LLM 返回 tool_calls
|
|
590
|
+
↓
|
|
591
|
+
assistant 消息落库 → set_pending({assistant_seq, tool_call_ids, tool_calls})
|
|
592
|
+
↓
|
|
593
|
+
tool A 落库 → 自动从 _unresolved 移除 A → set_pending(剩余)
|
|
594
|
+
↓ (process killed here)
|
|
595
|
+
进程重启 → send() 检测 pending → SessionPendingError
|
|
596
|
+
├ resume() → 跑剩余 tool_calls,pending 清零,继续
|
|
597
|
+
└ abort_pending() → 写 <aborted> tool 消息,pending 清零,下次 send 即可继续
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
---
|
|
601
|
+
|
|
602
|
+
## 9. 测试
|
|
603
|
+
|
|
604
|
+
```bash
|
|
605
|
+
# 全跑(含真实 LLM,要 .env 配 OPENAI_COMPAT_*)
|
|
606
|
+
pytest
|
|
607
|
+
|
|
608
|
+
# 只跑单元测试(不连真实 LLM)
|
|
609
|
+
pytest -m "not real_llm"
|
|
610
|
+
|
|
611
|
+
# 跳过真实 LLM
|
|
612
|
+
pytest --no-real
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
| 目录 | 内容 |
|
|
616
|
+
|---|---|
|
|
617
|
+
| `tests/unit/` | 纯控制流 / 契约测试,fake LLM |
|
|
618
|
+
| `tests/integration/` | 多组件场景,fake LLM |
|
|
619
|
+
| `tests/real/` | 跑真实 DashScope;缺 env 自动 skip |
|
|
620
|
+
|
|
621
|
+
`tests/real/judge.py` 提供 **LLM-as-judge**:业务方在测试里调 `assert_passes(question, answer, rubric)`,
|
|
622
|
+
内部 spawn 一个 power-loop 作 evaluator,按 rubric 返回 `{passed, reason}` JSON。
|
|
623
|
+
专门解决 LLM 输出非确定性下的断言难题。
|
|
624
|
+
|
|
625
|
+
---
|
|
626
|
+
|
|
627
|
+
## 10. Roadmap & Changelog
|
|
628
|
+
|
|
629
|
+
- 详细路线:[`ROADMAP.md`](ROADMAP.md)
|
|
630
|
+
- 版本记录:[`CHANGELOG.md`](CHANGELOG.md)
|
|
631
|
+
|
|
632
|
+
Issues / PRs welcome.
|