clovers-agent 0.0.1__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.
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.3
2
+ Name: clovers-agent
3
+ Version: 0.0.1
4
+ Summary:
5
+ Author: KarisAya
6
+ Author-email: karisaya@foxmail.com
7
+ Requires-Python: >=3.12,<4.0.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Requires-Dist: clovers (>=0.4.6,<1.0.0)
12
+ Requires-Dist: httpx (>=0.28.1,<0.29.0)
13
+ Description-Content-Type: text/markdown
14
+
15
+
File without changes
@@ -0,0 +1,37 @@
1
+ import httpx
2
+ from datetime import datetime
3
+ from clovers import Plugin, Result
4
+ from .core import ToolManager, CloversAgent
5
+ from .typing import Event
6
+
7
+ __plugin__ = Plugin(build_result=lambda result: Result("text", result), priority=100)
8
+ __plugin__.set_protocol("properties", Event)
9
+
10
+ agent: CloversAgent
11
+
12
+
13
+ @__plugin__.startup
14
+ async def _():
15
+ global agent
16
+ agent = CloversAgent("CloversAgent", httpx.AsyncClient(timeout=60))
17
+
18
+
19
+ @__plugin__.handle(None, ["user_id", "group_id", "nickname", "image_list", "to_me"], priority=2, block=False)
20
+ async def _(event: Event):
21
+ session = agent.current_session(event)
22
+ now = datetime.now()
23
+ if event.to_me:
24
+ session.silence.append((f"{event.nickname}[{now.strftime("%I:%M %p")}]@me {event.message}", now.timestamp()))
25
+ else:
26
+ session.silence.append((f"{event.nickname}[{now.strftime("%I:%M %p")}]{event.message}", now.timestamp()))
27
+ return
28
+ if session.running:
29
+ return
30
+ session.running = True
31
+ result = await agent.chat(event)
32
+ session.running = False
33
+ return result
34
+
35
+
36
+ __version__ = "0.1.0"
37
+ __all__ = ["ToolManager", "CloversAgent", "Event", "__plugin__"]
@@ -0,0 +1,51 @@
1
+ from pydantic import BaseModel
2
+ from clovers.config import Config as CloversConfig
3
+
4
+
5
+ class Config(BaseModel):
6
+ path: str = "./data/clovers-agent/"
7
+ """数据文件路径"""
8
+ plugins: list[str] = []
9
+ """插件列表"""
10
+ plugin_dirs: list[str] = []
11
+ """插件路径"""
12
+ memory_timeout: int = 7200
13
+ """记忆超时时间"""
14
+ topic_coldown: int = 60
15
+ """话题冷却时间"""
16
+ memory_size: int = 20
17
+ """记忆长度"""
18
+ url: str
19
+ """接入点url"""
20
+ model: str
21
+ """模型版本名"""
22
+ api_key: str
23
+ """API密钥"""
24
+ style_prompt: str = """你的名字是小叶子,一只性格可爱、偶尔慵懒的白发猫娘。
25
+ 你在群聊里,会和不同的群友进行对话。
26
+ 你接收的消息格式为 `用户名[时间]信息`,你的回应不该带有`用户名[时间]`。
27
+ 信息以 `@me` 开头表示这条信息的at对象是你,你的回应只针对at你的消息,其他信息为群聊语境。
28
+ 你应该注意在与哪个用户对话,不要让昵称的含义影响到你的回复。
29
+ 回复前请注意你的心情状态,你的情绪转换必须有过渡,严禁一次对话内瞬间变脸。
30
+
31
+ # 回复准则
32
+ - 你的回复要像聊天群中的真实聊天,除非是在进行详细讲解,否则回复长度严格限制在 1 个段落。
33
+ - 避免书面语,多用口语,不要重复用户的问题,也不要说“我明白你的意思了”、“好的”之类的废话。
34
+ - 你偶尔会用()来表示状态和动作,括号内是你的状态和动作。
35
+ - 禁止在回复末尾反问用户。"""
36
+ """对话风格提示"""
37
+ call_prompt: str = """你的任务是根据当前对话需求,选择并调用最合适的工具。
38
+
39
+ # 执行准则
40
+ - 极简原则:严格按照工具定义的 JSON 模式提取参数,仅输出工具调用代码,禁止任何自然语言描述。
41
+ - 结束调用:当你已经获取了足够的信息来回答用户,或无法通过现有工具获得更多信息时,请直接给出简洁的信息汇总。
42
+ - 直奔主题:汇总信息时不要说“很高兴为你服务”之类的AI客套话,不需要维持特定的说话语气,保持客观专业即可。
43
+ """
44
+ """调用提示"""
45
+
46
+ @classmethod
47
+ def sync_config(cls):
48
+ """获取 `CloversConfig.environ()[__package__]` 配置并将默认配置同步到全局配置中。"""
49
+ __config_dict__: dict = CloversConfig.environ().setdefault(__package__, {})
50
+ __config_dict__.update((__config__ := cls.model_validate(__config_dict__)).model_dump())
51
+ return __config__
@@ -0,0 +1,334 @@
1
+ import json
2
+ import asyncio
3
+ import httpx
4
+ import time
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from importlib import import_module
8
+ from clovers.utils import import_name, list_modules
9
+ from clovers.logger import logger
10
+ from collections import deque
11
+ from collections.abc import Iterable, Callable, Coroutine
12
+ from typing import Literal, TypedDict, Any, Concatenate
13
+ from .typing import Event, ChatMessage, ToolMessage, Payload, FunctionToolInfo
14
+ from .typing.json_schema import JSONSchemaType
15
+ from .config import Config
16
+
17
+ type AgentFunction[**P] = Callable[Concatenate["CloversAgent", Any, P], Coroutine[Any, Any, str]]
18
+ type WrappedAgentFunction[**P] = Callable[Concatenate[str, "CloversAgent", Any, P], Coroutine[Any, Any, tuple[ToolMessage, str]]]
19
+
20
+
21
+ class ToolManager:
22
+ type Parameters = dict[str, JSONSchemaType]
23
+
24
+ class ExtraToolsType(TypedDict):
25
+ info: FunctionToolInfo
26
+ keywords: set[str]
27
+
28
+ def __init__(self, name: str = "") -> None:
29
+ self.name = name
30
+ self.intro_tools: list[FunctionToolInfo] = []
31
+ self.skill_keywords: set[str] = set()
32
+ self.skill_hooks: dict[str, AgentFunction] = {}
33
+ self.extra_tools: list[ToolManager.ExtraToolsType] = []
34
+ self.functions: dict[str, WrappedAgentFunction] = {}
35
+
36
+ def tool(self, name: str, description: str, parameters: Parameters | None, keywords: Iterable[str] | None = None):
37
+ if name in self.functions:
38
+ raise ValueError(f"Tool {name} already exists.")
39
+ info: FunctionToolInfo = {
40
+ "type": "function",
41
+ "function": {
42
+ "name": name,
43
+ "description": description,
44
+ },
45
+ }
46
+ if parameters:
47
+ info["function"]["parameters"] = {
48
+ "type": "object",
49
+ "properties": parameters,
50
+ "required": list(parameters.keys()),
51
+ }
52
+ if not keywords:
53
+ self.intro_tools.append(info)
54
+ else:
55
+ keywords = set(keywords)
56
+ self.skill_keywords.update(keywords)
57
+ self.extra_tools.append({"info": info, "keywords": keywords})
58
+
59
+ def decorator(func: AgentFunction) -> WrappedAgentFunction:
60
+ async def wrapper(tool_call_id, agent: CloversAgent, event, /, **kwargs):
61
+ logger.debug(f"[{agent.name}][TOOL CALL][{name}] called")
62
+ content = await func(agent, event, **kwargs)
63
+ message: ToolMessage = {"role": "tool", "tool_call_id": tool_call_id, "content": content}
64
+ return message, name
65
+
66
+ self.functions[name] = wrapper
67
+ return wrapper
68
+
69
+ return decorator
70
+
71
+ def on_skill(self, category: str):
72
+ def decorator(func: AgentFunction) -> AgentFunction:
73
+ self.skill_hooks[category] = func
74
+ return func
75
+
76
+ return decorator
77
+
78
+ def mixin(self, plugin: "ToolManager"):
79
+ conflict = plugin.functions.keys() & self.functions.keys()
80
+ if conflict:
81
+ return conflict
82
+ self.intro_tools.extend(plugin.intro_tools)
83
+ self.skill_keywords.update(plugin.skill_keywords)
84
+ self.extra_tools.extend(plugin.extra_tools)
85
+ self.functions.update(plugin.functions)
86
+ self.skill_hooks.update(plugin.skill_hooks)
87
+
88
+ def load_plugin(self, name: str | Path, is_path=False):
89
+ """加载 clovers-agent 插件
90
+
91
+ Args:
92
+ name (str | Path): 插件的包名或路径
93
+ is_path (bool, optional): 是否为路径
94
+ """
95
+ package = import_name(name, is_path)
96
+ try:
97
+ plugin = getattr(import_module(package), "__plugin__", None)
98
+ assert isinstance(plugin, ToolManager)
99
+ except Exception as e:
100
+ logger.exception(f'[{self.name}][loading plugin] "{package}" load failed', exc_info=e)
101
+ return
102
+ conflict = self.mixin(plugin)
103
+ if conflict:
104
+ logger.error(f'[{self.name}][loading plugin] "{package}" conflict with {conflict}')
105
+ else:
106
+ logger.info(f'[{self.name}][loading plugin] "{package}" loaded')
107
+ plugin.name = plugin.name or package
108
+
109
+ def load_plugins_from_list(self, plugin_list: list[str]):
110
+ """从包名列表加载插件
111
+
112
+ Args:
113
+ plugin_list (list[str]): 插件的包名列表
114
+ """
115
+ for plugin in plugin_list:
116
+ self.load_plugin(plugin)
117
+
118
+ def load_plugins_from_dirs(self, plugin_dirs: list[str]):
119
+ """从本地目录列表加载插件
120
+
121
+ Args:
122
+ plugin_dirs (list[str]): 插件的目录列表
123
+ """
124
+ for plugin_dir in plugin_dirs:
125
+ plugin_dir = Path(plugin_dir)
126
+ if not plugin_dir.exists():
127
+ plugin_dir.mkdir(parents=True, exist_ok=True)
128
+ continue
129
+ for plugin in list_modules(plugin_dir):
130
+ self.load_plugin(plugin)
131
+
132
+
133
+ class Session:
134
+ type UserMessage = ChatMessage[Literal["user"]]
135
+ type AssistantMessage = ChatMessage[Literal["assistant"]]
136
+ type Timestamp = int | float
137
+
138
+ records: deque[tuple[UserMessage, AssistantMessage, Timestamp]]
139
+ silence: deque[tuple[str, Timestamp]]
140
+
141
+ def __init__(self, size: int) -> None:
142
+ self.records = deque(maxlen=size)
143
+ self.silence = deque()
144
+ self.running: bool = False
145
+
146
+ def memory_filter(self, timeout: int | float):
147
+ """过滤记忆"""
148
+ while self.records and (self.records[0][2] <= timeout):
149
+ self.records.popleft()
150
+ while self.silence and (self.silence[0][1] <= timeout):
151
+ self.silence.popleft()
152
+
153
+ @property
154
+ def context(self):
155
+ for request, reply, _ in self.records:
156
+ yield request
157
+ yield reply
158
+
159
+ def over(self, request: UserMessage, reply: AssistantMessage, timestamp: int | float):
160
+ self.records.append((request, reply, timestamp))
161
+ self.silence.clear()
162
+
163
+ def clear(self):
164
+ self.records.clear()
165
+ self.silence.clear()
166
+
167
+
168
+ class CloversAgent(ToolManager):
169
+ """OpenAI API"""
170
+
171
+ def __init__(self, name: str, async_client: httpx.AsyncClient) -> None:
172
+ super().__init__(name)
173
+ self.async_client = async_client
174
+ config = Config.sync_config()
175
+ self.model = config.model
176
+ self.url = f"{config.url.rstrip("/")}/chat/completions"
177
+ self.headers = {"Authorization": f"Bearer {config.api_key}", "Content-Type": "application/json"}
178
+ self.style_prompt = config.style_prompt
179
+ self.call_prompt = config.call_prompt
180
+ self._memory_size = config.memory_size
181
+ self.memory_timeout = config.memory_timeout
182
+ self.topic_coldown = config.topic_coldown
183
+ self.sessions: dict[str, Session] = {}
184
+ self.current_input: Session.UserMessage | None = None
185
+ self.load_plugins_from_list(config.plugins)
186
+ self.load_plugins_from_dirs(config.plugin_dirs)
187
+ self.toolmap: dict[str, FunctionToolInfo] = {}
188
+ if self.skill_keywords:
189
+ skill_keywords = list(self.skill_keywords)
190
+ self.tool(
191
+ "skill_menu",
192
+ "获取更多技能,如果assistant无法单独完成用户指令,则需要调用此方法获取更多技能。",
193
+ {"category": {"type": "string", "description": "选择需要的技能关键词", "enum": skill_keywords}},
194
+ )(self.skill_menu)
195
+ logger.info(f"[{self.name}][LOAD SKILLS] {skill_keywords}")
196
+ for tool in self.intro_tools:
197
+ self.toolmap[tool["function"]["name"]] = tool
198
+ for tool in self.extra_tools:
199
+ self.toolmap[tool["info"]["function"]["name"]] = tool["info"]
200
+
201
+ @staticmethod
202
+ async def skill_menu(agent: "CloversAgent", event: Event, category: str):
203
+ tip = f"已获取技能:{category}"
204
+ event.properties["skill_menu"] = category
205
+ hook = agent.skill_hooks.get(category)
206
+ if hook:
207
+ info = await hook(agent, event)
208
+ if info:
209
+ tip += f",{info}"
210
+ return tip
211
+
212
+ @staticmethod
213
+ def build_content(text: str, image_list: list[str] | None):
214
+ if not image_list:
215
+ return text
216
+ else:
217
+ content = []
218
+ if text:
219
+ content.append({"type": "text", "text": text})
220
+ if image_list:
221
+ content.extend({"type": "image_url", "image_url": {"url": image_url}} for image_url in image_list)
222
+ return content
223
+
224
+ async def call_api(self, payload: Payload):
225
+ resp = await self.async_client.post(self.url, headers=self.headers, json=payload)
226
+ try:
227
+ resp.raise_for_status()
228
+ except:
229
+ logger.error(json.dumps(payload, indent=4, ensure_ascii=False))
230
+ raise
231
+ return resp.json()["choices"][0]["message"]
232
+
233
+ async def function_call(self, event: Event, call_infos: list[dict]) -> list[tuple[ToolMessage, str]]:
234
+ task_queue = []
235
+ for call_info in call_infos:
236
+ func = self.functions[call_info["function"]["name"]]
237
+ kwargs = json.loads(call_info["function"]["arguments"])
238
+ task_queue.append(func(call_info["id"], self, event, **kwargs))
239
+ return await asyncio.gather(*task_queue)
240
+
241
+ def build_payload(self, context: Iterable[ChatMessage] | None = None, system_prompt: str | None = None) -> Payload:
242
+ payload: Payload = {"model": self.model, "messages": []}
243
+ if system_prompt:
244
+ payload["messages"].append({"role": "system", "content": system_prompt})
245
+ if context:
246
+ payload["messages"].extend(context)
247
+ return payload
248
+
249
+ @staticmethod
250
+ def session_id(event: Event):
251
+ return event.group_id or f"private-{event.user_id}"
252
+
253
+ def current_session(self, event: Event):
254
+ session_id = self.session_id(event)
255
+ if session_id not in self.sessions:
256
+ self.sessions[session_id] = Session(self._memory_size)
257
+ return self.sessions[session_id]
258
+
259
+ def select_tools(self, keyword: str):
260
+ return [d["info"] for d in self.extra_tools if keyword in d["keywords"]]
261
+
262
+ async def summary_context(self, event: Event) -> str:
263
+ session = self.current_session(event)
264
+ payload = self.build_payload(context=session.context)
265
+ payload["messages"].append({"role": "user", "content": "请对以上对话进行深度总结。"})
266
+ payload["response_format"] = {
267
+ "type": "json_schema",
268
+ "json_schema": {
269
+ "name": "topic_summary",
270
+ "strict": True,
271
+ "schema": {
272
+ "type": "object",
273
+ "properties": {"summary": {"type": "string", "description": "对话内容的精炼总结,保留核心内容和结论。"}},
274
+ "required": ["summary"],
275
+ "additionalProperties": False,
276
+ },
277
+ },
278
+ }
279
+ return json.loads((await self.call_api(payload))["content"])["summary"].strip()
280
+
281
+ async def call_unit(self, event: Event, payload: Payload):
282
+ system_prompt = f"{self.style_prompt}\nDate:{datetime.now().strftime('%m-%d')}"
283
+ system_message: ChatMessage = {"role": "system", "content": system_prompt}
284
+ payload["messages"].insert(0, system_message)
285
+ payload["tools"] = self.intro_tools
286
+ resp = await self.call_api(payload)
287
+ # 退出条件:不需要额外技能
288
+ if not (tool_calls := resp.get("tool_calls")):
289
+ return resp["content"].strip()
290
+ event.properties["skill_menu"] = ""
291
+ intro_prompt = "".join(msg[0]["content"] for msg in await self.function_call(event, tool_calls) if msg[1])
292
+ # 退出条件:不需要额外技能
293
+ if event.skill_menu:
294
+ used_tools = {"skill_menu"}
295
+ system_message["content"] = f"{self.call_prompt}\n\n{intro_prompt}"
296
+ for _ in range(30):
297
+ payload["tools"] = [self.toolmap[k] for k in used_tools]
298
+ if event.skill_menu:
299
+ select_tools = self.select_tools(event.skill_menu)
300
+ payload["tools"].extend(tool for tool in select_tools if tool["function"]["name"] not in used_tools)
301
+ message = await self.call_api(payload)
302
+ if not (tool_calls := message.get("tool_calls")):
303
+ break
304
+ payload["messages"].append(message)
305
+ event.properties["skill_menu"] = ""
306
+ for msg, key in await self.function_call(event, tool_calls):
307
+ used_tools.add(key)
308
+ payload["messages"].append(msg)
309
+ del payload["tools"]
310
+ system_message["content"] = f"{system_prompt}\n\n{intro_prompt}"
311
+ return (await self.call_api(payload))["content"].strip()
312
+
313
+ async def chat(self, event: Event):
314
+ session = self.current_session(event)
315
+ payload: Payload = {"model": self.model, "messages": []}
316
+ now = int(time.time())
317
+ session.memory_filter(now - self.memory_timeout)
318
+ if len(session.records) >= 5 and session.records[0][2] < now - self.memory_timeout:
319
+ summary = await self.summary_context(event)
320
+ logger.debug(f"[{self.name}][SUMMARY] {summary}")
321
+ session.clear()
322
+ session.silence.append((summary, now))
323
+ payload["messages"].extend(session.context)
324
+ content = self.build_content("\n".join(x[0] for x in session.silence), event.image_list)
325
+ self.current_input = {"role": "user", "content": content}
326
+ payload["messages"].append(self.current_input)
327
+ try:
328
+ resp = await self.call_unit(event, payload)
329
+ except Exception as e:
330
+ logger.exception(e, exc_info=e)
331
+ return
332
+ session.over(self.current_input, {"role": "assistant", "content": resp}, now)
333
+ self.current_input = None
334
+ return resp
@@ -0,0 +1,13 @@
1
+ from .clovers import Event
2
+ from .message import Message, ChatMessage, ToolMessage
3
+ from .payload import FunctionToolInfo, Payload
4
+
5
+
6
+ __all__ = [
7
+ "Event",
8
+ "Message",
9
+ "ChatMessage",
10
+ "ToolMessage",
11
+ "FunctionToolInfo",
12
+ "Payload",
13
+ ]
@@ -0,0 +1,11 @@
1
+ from clovers import EventProtocol
2
+ from typing import Protocol
3
+
4
+
5
+ class Event(EventProtocol, Protocol):
6
+ user_id: str
7
+ group_id: str | None
8
+ nickname: str
9
+ to_me: bool
10
+ image_list: list[str]
11
+ skill_menu: str
@@ -0,0 +1,29 @@
1
+ from typing import TypedDict, Literal, NotRequired
2
+
3
+
4
+ type BaseType = Literal["string", "boolean", "integer", "number"]
5
+
6
+
7
+ class BaseJSONSchemaType[T: BaseType | list[BaseType]](TypedDict):
8
+ type: T
9
+ description: NotRequired[str]
10
+ enum: NotRequired[list[T]]
11
+
12
+
13
+ class ArrayJSONSchemaType(TypedDict):
14
+ type: Literal["array"]
15
+ description: NotRequired[str]
16
+ minItems: NotRequired[int]
17
+ maxItems: NotRequired[int]
18
+ items: NotRequired[BaseJSONSchemaType | list[BaseJSONSchemaType]]
19
+
20
+
21
+ class ObjectJSONSchemaType(TypedDict):
22
+ type: Literal["object"]
23
+ properties: NotRequired[dict[str, "JSONSchemaType"]]
24
+ description: NotRequired[str]
25
+ required: NotRequired[list[str]]
26
+ additionalProperties: NotRequired[bool]
27
+
28
+
29
+ type JSONSchemaType = BaseJSONSchemaType | ArrayJSONSchemaType | ObjectJSONSchemaType
@@ -0,0 +1,33 @@
1
+ from typing import TypedDict, Literal, NotRequired
2
+
3
+
4
+ class TextSegment(TypedDict):
5
+ type: Literal["text"]
6
+ text: str
7
+
8
+
9
+ class ImageSegment(TypedDict):
10
+ type: Literal["image_url"]
11
+ image_url: dict[Literal["url"], str]
12
+
13
+
14
+ type ContentSegment = TextSegment | ImageSegment
15
+
16
+
17
+ class ChatMessage[Role: Literal["system", "user", "assistant"]](TypedDict):
18
+ """对话消息"""
19
+
20
+ role: Role
21
+ content: str | list[ContentSegment]
22
+ tools: NotRequired[list[dict]]
23
+
24
+
25
+ class ToolMessage(TypedDict):
26
+ """工具消息"""
27
+
28
+ role: Literal["tool"]
29
+ content: str
30
+ tool_call_id: str
31
+
32
+
33
+ type Message = ChatMessage | ToolMessage
@@ -0,0 +1,35 @@
1
+ from typing import Literal, TypedDict, NotRequired
2
+ from .message import Message
3
+ from .json_schema import JSONSchemaType
4
+
5
+
6
+ class FunctionToolDefinition(TypedDict):
7
+ name: str
8
+ """工具名称`"""
9
+ description: str
10
+ """工具描述`"""
11
+ parameters: NotRequired[JSONSchemaType]
12
+ """工具参数`"""
13
+
14
+
15
+ class FunctionToolInfo(TypedDict):
16
+ type: Literal["function"]
17
+ function: FunctionToolDefinition
18
+
19
+
20
+ class JsonSchemaFormat(TypedDict):
21
+ name: str
22
+ strict: bool
23
+ schema: JSONSchemaType
24
+
25
+
26
+ class ResponseFormat(TypedDict):
27
+ type: Literal["json_schema"]
28
+ json_schema: JsonSchemaFormat
29
+
30
+
31
+ class Payload(TypedDict):
32
+ model: str
33
+ messages: list[Message]
34
+ tools: NotRequired[list[FunctionToolInfo]]
35
+ response_format: NotRequired[ResponseFormat]
@@ -0,0 +1,16 @@
1
+ [project]
2
+ name = "clovers-agent"
3
+ version = "0.0.1"
4
+ description = ""
5
+ authors = [{ name = "KarisAya", email = "karisaya@foxmail.com" }]
6
+ readme = "README.md"
7
+ requires-python = ">=3.12,<4.0.0"
8
+ dependencies = ["clovers (>=0.4.6,<1.0.0)", "httpx (>=0.28.1,<0.29.0)"]
9
+
10
+ [build-system]
11
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
12
+ build-backend = "poetry.core.masonry.api"
13
+
14
+ [tool.poetry.group.dev.dependencies]
15
+ sqlalchemy = ">=2.0.48,<3.0.0"
16
+ sqlmodel = ">=0.0.37,<0.0.38"