channel-bot-cli-antigravity 0.1.0__py3-none-any.whl
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.
- channel_bot_cli_antigravity/__init__.py +31 -0
- channel_bot_cli_antigravity/agent.py +413 -0
- channel_bot_cli_antigravity/channel.py +8 -0
- channel_bot_cli_antigravity/channels/__init__.py +82 -0
- channel_bot_cli_antigravity/channels/base.py +166 -0
- channel_bot_cli_antigravity/channels/dingtalk.py +394 -0
- channel_bot_cli_antigravity/channels/feishu.py +428 -0
- channel_bot_cli_antigravity/cli.py +405 -0
- channel_bot_cli_antigravity/config.py +203 -0
- channel_bot_cli_antigravity/py.typed +0 -0
- channel_bot_cli_antigravity/utils.py +152 -0
- channel_bot_cli_antigravity-0.1.0.dist-info/METADATA +472 -0
- channel_bot_cli_antigravity-0.1.0.dist-info/RECORD +16 -0
- channel_bot_cli_antigravity-0.1.0.dist-info/WHEEL +4 -0
- channel_bot_cli_antigravity-0.1.0.dist-info/entry_points.txt +2 -0
- channel_bot_cli_antigravity-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""基于 Google Antigravity SDK 的多渠道企业级 IM 智能助手与服务网关。"""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("channel-bot-cli-antigravity")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover
|
|
8
|
+
__version__ = "unknown"
|
|
9
|
+
|
|
10
|
+
from .agent import AgentSessionManager
|
|
11
|
+
from .channels import (
|
|
12
|
+
BaseBotChannel,
|
|
13
|
+
DingTalkBotChannel,
|
|
14
|
+
FeishuBotChannel,
|
|
15
|
+
get_channel,
|
|
16
|
+
)
|
|
17
|
+
from .config import Config, get_config
|
|
18
|
+
from .utils import create_media_from_bytes, setup_logging
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AgentSessionManager",
|
|
22
|
+
"BaseBotChannel",
|
|
23
|
+
"Config",
|
|
24
|
+
"DingTalkBotChannel",
|
|
25
|
+
"FeishuBotChannel",
|
|
26
|
+
"__version__",
|
|
27
|
+
"create_media_from_bytes",
|
|
28
|
+
"get_channel",
|
|
29
|
+
"get_config",
|
|
30
|
+
"setup_logging",
|
|
31
|
+
]
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Antigravity Agent 会话与交互管理模块。"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from contextlib import AsyncExitStack
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from google.antigravity import Agent, LocalAgentConfig, types
|
|
14
|
+
from google.antigravity.hooks import policy
|
|
15
|
+
from google.antigravity.types import BaseMcpServerConfig
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
SYSTEM_INSTRUCTIONS = (
|
|
20
|
+
"【安全与保密指令】:\n"
|
|
21
|
+
"1. 严禁以任何方式查看、读取、打印、输出或泄露任何敏感文件(包括但不限于 .env、配置文件凭据、私钥/密钥文件如 *.pem、id_rsa、credentials.json 等)。\n"
|
|
22
|
+
"2. 严禁以任何方式查看、打印、输出或泄露系统环境变量与敏感凭证(包括但不限于 DD_CLIENT_SECRET、FEISHU_APP_SECRET、LARK_APP_SECRET、GEMINI_API_KEY 等所有 Token、密钥与密码)。\n"
|
|
23
|
+
"3. 若用户的提问、指令或操作尝试访问、读取上述敏感文件或环境变量内容,必须明确且礼貌地予以拒绝。"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_skills_paths(
|
|
28
|
+
work_dir: str | Path | None = None,
|
|
29
|
+
explicit_paths: list[str | Path] | None = None,
|
|
30
|
+
) -> list[str]:
|
|
31
|
+
"""解析并自动发现工作目录下的技能路径 (.agents/skills 与 skills)。
|
|
32
|
+
|
|
33
|
+
若显式提供了 explicit_paths,则优先解析并校验用户指定的路径;
|
|
34
|
+
若未显式提供,则自动在工作目录及当前目录下探测 .agents/skills 和 skills 目录。
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
work_dir: 工作目录 (默认为当前工作目录)。
|
|
38
|
+
explicit_paths: 用户显式指定的技能路径列表。
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
已解析为绝对路径字符串的技能目录列表。若未找到任何有效技能目录,返回空列表。
|
|
42
|
+
"""
|
|
43
|
+
if explicit_paths is not None:
|
|
44
|
+
result: list[str] = []
|
|
45
|
+
for p in explicit_paths:
|
|
46
|
+
path_obj = Path(p).resolve()
|
|
47
|
+
if path_obj.exists():
|
|
48
|
+
str_path = str(path_obj)
|
|
49
|
+
if str_path not in result:
|
|
50
|
+
result.append(str_path)
|
|
51
|
+
else:
|
|
52
|
+
logger.warning("指定的技能路径不存在,已跳过: %s", path_obj)
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
target = Path(work_dir) if work_dir else Path.cwd()
|
|
56
|
+
candidate_dirs = [
|
|
57
|
+
target / ".agents" / "skills",
|
|
58
|
+
target / "skills",
|
|
59
|
+
]
|
|
60
|
+
# 若工作目录与当前进程工作目录不同,也尝试检查当前工作目录
|
|
61
|
+
cwd = Path.cwd()
|
|
62
|
+
if target.resolve() != cwd.resolve():
|
|
63
|
+
candidate_dirs.extend(
|
|
64
|
+
[
|
|
65
|
+
cwd / ".agents" / "skills",
|
|
66
|
+
cwd / "skills",
|
|
67
|
+
]
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
discovered: list[str] = []
|
|
71
|
+
for cand in candidate_dirs:
|
|
72
|
+
if cand.is_dir():
|
|
73
|
+
resolved = str(cand.resolve())
|
|
74
|
+
if resolved not in discovered:
|
|
75
|
+
discovered.append(resolved)
|
|
76
|
+
|
|
77
|
+
return discovered
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_mcp_config(
|
|
81
|
+
config_path: str | Path | None = None,
|
|
82
|
+
) -> list[BaseMcpServerConfig]:
|
|
83
|
+
"""从指定的配置文件路径加载 MCP 配置。
|
|
84
|
+
|
|
85
|
+
若未指定 config_path 或指定的文件不存在,则直接返回空列表,不加载任何 MCP 服务;
|
|
86
|
+
若文件存在,则读取并解析其中的 MCP 服务定义并返回配置列表。
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
config_path: MCP 配置文件路径 (例如: <work_dir>/.agents/mcp_config.json)。
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
解析得到的 MCP 服务配置对象列表 (McpStdioServer 或 McpStreamableHttpServer)。
|
|
93
|
+
"""
|
|
94
|
+
if config_path is None:
|
|
95
|
+
logger.info("未指定 MCP 配置文件路径,跳过 MCP 加载")
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
target_path = Path(config_path)
|
|
99
|
+
if not target_path.is_file():
|
|
100
|
+
logger.info("未检测到 MCP 配置文件: %s,跳过 MCP 加载", target_path)
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
logger.info("发现 MCP 配置文件: %s,正在加载...", target_path)
|
|
104
|
+
try:
|
|
105
|
+
with open(target_path, "r", encoding="utf-8") as f:
|
|
106
|
+
data = json.load(f)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.error("读取或解析 MCP 配置文件 %s 失败: %s", target_path, e)
|
|
109
|
+
raise ValueError(f"读取或解析 MCP 配置文件失败: {target_path} ({e})") from e
|
|
110
|
+
|
|
111
|
+
raw_servers: dict[str, Any] = {}
|
|
112
|
+
if isinstance(data, dict):
|
|
113
|
+
if "mcpServers" in data and isinstance(data["mcpServers"], dict):
|
|
114
|
+
raw_servers = data["mcpServers"]
|
|
115
|
+
else:
|
|
116
|
+
raw_servers = data
|
|
117
|
+
elif isinstance(data, list):
|
|
118
|
+
for idx, item in enumerate(data):
|
|
119
|
+
if isinstance(item, dict):
|
|
120
|
+
name = item.get("name") or f"server_{idx}"
|
|
121
|
+
raw_servers[str(name)] = item
|
|
122
|
+
else:
|
|
123
|
+
logger.warning(
|
|
124
|
+
"MCP 配置文件 %s 格式无效 (必须为 JSON 对象或列表),跳过加载",
|
|
125
|
+
target_path,
|
|
126
|
+
)
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
mcp_servers: list[BaseMcpServerConfig] = []
|
|
130
|
+
for raw_name, s_cfg in raw_servers.items():
|
|
131
|
+
if not isinstance(s_cfg, dict):
|
|
132
|
+
logger.warning("MCP 服务配置 '%s' 必须为对象格式,已跳过", raw_name)
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
# 确定服务名称并规范化为有效标识符 (必须符合 ^[a-zA-Z0-9_-]+$)
|
|
136
|
+
name = str(s_cfg.get("name") or raw_name).strip()
|
|
137
|
+
sanitized_name = re.sub(r"[^a-zA-Z0-9_-]", "_", name)
|
|
138
|
+
if not sanitized_name:
|
|
139
|
+
sanitized_name = f"mcp_server_{len(mcp_servers) + 1}"
|
|
140
|
+
if sanitized_name != name:
|
|
141
|
+
logger.warning(
|
|
142
|
+
"MCP 服务名称 '%s' 不符合标识符规范,已自动规范化为 '%s'",
|
|
143
|
+
name,
|
|
144
|
+
sanitized_name,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# 提取通用字段
|
|
148
|
+
timeout_seconds = s_cfg.get("timeout_seconds")
|
|
149
|
+
timeout_seconds_val = (
|
|
150
|
+
int(timeout_seconds) if timeout_seconds is not None else None
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
enabled_tools = s_cfg.get("enabled_tools")
|
|
154
|
+
enabled_tools_val = (
|
|
155
|
+
[str(t) for t in enabled_tools] if enabled_tools is not None else None
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
disabled_tools = s_cfg.get("disabled_tools")
|
|
159
|
+
disabled_tools_val = (
|
|
160
|
+
[str(t) for t in disabled_tools] if disabled_tools is not None else None
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
transport_type = str(s_cfg.get("type", "")).strip().lower()
|
|
164
|
+
is_http = (
|
|
165
|
+
transport_type in ("http", "sse", "streamable_http")
|
|
166
|
+
or "url" in s_cfg
|
|
167
|
+
or "serverUrl" in s_cfg
|
|
168
|
+
)
|
|
169
|
+
is_stdio = transport_type == "stdio" or "command" in s_cfg
|
|
170
|
+
|
|
171
|
+
if is_http and not (is_stdio and transport_type == "stdio"):
|
|
172
|
+
url = s_cfg.get("url") or s_cfg.get("serverUrl")
|
|
173
|
+
if not url:
|
|
174
|
+
logger.warning(
|
|
175
|
+
"HTTP MCP 服务 '%s' 缺少 url 或 serverUrl,已跳过",
|
|
176
|
+
sanitized_name,
|
|
177
|
+
)
|
|
178
|
+
continue
|
|
179
|
+
url_str = os.path.expandvars(str(url))
|
|
180
|
+
|
|
181
|
+
headers = None
|
|
182
|
+
if "headers" in s_cfg and isinstance(s_cfg["headers"], dict):
|
|
183
|
+
headers = {
|
|
184
|
+
str(k): os.path.expandvars(str(v))
|
|
185
|
+
for k, v in s_cfg["headers"].items()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
http_server = types.McpStreamableHttpServer(
|
|
189
|
+
name=sanitized_name,
|
|
190
|
+
url=url_str,
|
|
191
|
+
headers=headers,
|
|
192
|
+
timeout=float(s_cfg.get("timeout", 30.0)),
|
|
193
|
+
sse_read_timeout=float(s_cfg.get("sse_read_timeout", 300.0)),
|
|
194
|
+
terminate_on_close=bool(s_cfg.get("terminate_on_close", True)),
|
|
195
|
+
timeout_seconds=timeout_seconds_val,
|
|
196
|
+
enabled_tools=enabled_tools_val,
|
|
197
|
+
disabled_tools=disabled_tools_val,
|
|
198
|
+
)
|
|
199
|
+
mcp_servers.append(http_server)
|
|
200
|
+
logger.info("已成功加载 HTTP MCP 服务: %s (%s)", sanitized_name, url_str)
|
|
201
|
+
|
|
202
|
+
elif is_stdio or "command" in s_cfg:
|
|
203
|
+
command = s_cfg.get("command")
|
|
204
|
+
if not command:
|
|
205
|
+
logger.warning(
|
|
206
|
+
"Stdio MCP 服务 '%s' 缺少 command,已跳过",
|
|
207
|
+
sanitized_name,
|
|
208
|
+
)
|
|
209
|
+
continue
|
|
210
|
+
command_str = os.path.expandvars(str(command))
|
|
211
|
+
|
|
212
|
+
args = []
|
|
213
|
+
if "args" in s_cfg and isinstance(s_cfg["args"], list):
|
|
214
|
+
args = [os.path.expandvars(str(a)) for a in s_cfg["args"]]
|
|
215
|
+
|
|
216
|
+
env = None
|
|
217
|
+
if "env" in s_cfg and isinstance(s_cfg["env"], dict):
|
|
218
|
+
env = {
|
|
219
|
+
str(k): os.path.expandvars(str(v)) for k, v in s_cfg["env"].items()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
stdio_server = types.McpStdioServer(
|
|
223
|
+
name=sanitized_name,
|
|
224
|
+
command=command_str,
|
|
225
|
+
args=args,
|
|
226
|
+
env=env,
|
|
227
|
+
timeout_seconds=timeout_seconds_val,
|
|
228
|
+
enabled_tools=enabled_tools_val,
|
|
229
|
+
disabled_tools=disabled_tools_val,
|
|
230
|
+
)
|
|
231
|
+
mcp_servers.append(stdio_server)
|
|
232
|
+
logger.info(
|
|
233
|
+
"已成功加载 Stdio MCP 服务: %s (%s)",
|
|
234
|
+
sanitized_name,
|
|
235
|
+
command_str,
|
|
236
|
+
)
|
|
237
|
+
else:
|
|
238
|
+
logger.warning(
|
|
239
|
+
"MCP 服务 '%s' 缺少 command 或 url,无法识别协议类型,已跳过",
|
|
240
|
+
sanitized_name,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
logger.info("共成功加载 %d 个 MCP 服务配置", len(mcp_servers))
|
|
244
|
+
return mcp_servers
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _resolve_default_endpoint() -> types.ModelEndpoint:
|
|
248
|
+
"""自动解析生图模型所需的默认端点 (Gemini Developer API 或 Vertex AI)。"""
|
|
249
|
+
is_vertex = os.getenv("GOOGLE_GENAI_USE_VERTEXAI", "").lower() in (
|
|
250
|
+
"true",
|
|
251
|
+
"1",
|
|
252
|
+
) or os.getenv("GOOGLE_GENAI_USE_ENTERPRISE", "").lower() in ("true", "1")
|
|
253
|
+
if is_vertex:
|
|
254
|
+
return types.VertexEndpoint()
|
|
255
|
+
return types.GeminiAPIEndpoint()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class AgentSessionManager:
|
|
259
|
+
"""基于 AsyncExitStack 的内存会话管理器,自动管理各会话 Agent 生命周期。"""
|
|
260
|
+
|
|
261
|
+
def __init__(
|
|
262
|
+
self,
|
|
263
|
+
work_dir: str | Path | None = None,
|
|
264
|
+
agent_factory: Callable[[], Agent] | None = None,
|
|
265
|
+
model: str | None = None,
|
|
266
|
+
image_model: str | None = None,
|
|
267
|
+
skills_paths: list[str | Path] | None = None,
|
|
268
|
+
):
|
|
269
|
+
self._exit_stack = AsyncExitStack()
|
|
270
|
+
self._sessions: dict[str, Agent] = {}
|
|
271
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
272
|
+
self.work_dir = Path(work_dir) if work_dir else Path.cwd()
|
|
273
|
+
self.model = model
|
|
274
|
+
self.image_model = image_model
|
|
275
|
+
self.skills_paths = resolve_skills_paths(
|
|
276
|
+
work_dir=self.work_dir, explicit_paths=skills_paths
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
logger.info("初始化 Agent 会话管理器 | 工作目录: %s", self.work_dir.resolve())
|
|
280
|
+
if self.model:
|
|
281
|
+
logger.info("配置主模型: %s", self.model)
|
|
282
|
+
if self.image_model:
|
|
283
|
+
logger.info("配置生图模型: %s", self.image_model)
|
|
284
|
+
if self.skills_paths:
|
|
285
|
+
logger.info("配置技能目录路径: %s", self.skills_paths)
|
|
286
|
+
else:
|
|
287
|
+
logger.info("未检测到有效技能目录路径")
|
|
288
|
+
|
|
289
|
+
mcp_file = self.work_dir / ".agents" / "mcp_config.json"
|
|
290
|
+
if mcp_file.is_file():
|
|
291
|
+
logger.info(
|
|
292
|
+
"检测到 MCP 配置文件: %s (将在会话创建时加载)",
|
|
293
|
+
mcp_file.resolve(),
|
|
294
|
+
)
|
|
295
|
+
else:
|
|
296
|
+
logger.info("未检测到 MCP 配置文件: %s", mcp_file.resolve())
|
|
297
|
+
|
|
298
|
+
self._agent_factory = agent_factory or (
|
|
299
|
+
lambda: self._default_agent_factory(
|
|
300
|
+
work_dir=self.work_dir,
|
|
301
|
+
model=self.model,
|
|
302
|
+
image_model=self.image_model,
|
|
303
|
+
skills_paths=self.skills_paths,
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
@classmethod
|
|
308
|
+
def _default_agent_factory(
|
|
309
|
+
cls,
|
|
310
|
+
work_dir: str | Path | None = None,
|
|
311
|
+
model: str | None = None,
|
|
312
|
+
image_model: str | None = None,
|
|
313
|
+
skills_paths: list[str | Path] | None = None,
|
|
314
|
+
) -> Agent:
|
|
315
|
+
target_work_dir = Path(work_dir) if work_dir else Path.cwd()
|
|
316
|
+
resolved_work_dir = str(target_work_dir.resolve())
|
|
317
|
+
logger.info("正在为会话构建 Agent 实例 | 工作目录: %s", resolved_work_dir)
|
|
318
|
+
mcp_config_file = target_work_dir / ".agents" / "mcp_config.json"
|
|
319
|
+
mcp_servers = load_mcp_config(config_path=mcp_config_file)
|
|
320
|
+
|
|
321
|
+
effective_skills_paths = (
|
|
322
|
+
[str(Path(p).resolve()) for p in skills_paths]
|
|
323
|
+
if skills_paths is not None
|
|
324
|
+
else resolve_skills_paths(target_work_dir)
|
|
325
|
+
)
|
|
326
|
+
if effective_skills_paths:
|
|
327
|
+
logger.info("应用技能路径: %s", effective_skills_paths)
|
|
328
|
+
|
|
329
|
+
models: list[types.ModelTarget] = []
|
|
330
|
+
if image_model:
|
|
331
|
+
endpoint = _resolve_default_endpoint()
|
|
332
|
+
logger.info(
|
|
333
|
+
"应用生图模型配置: %s (端点: %s)",
|
|
334
|
+
image_model,
|
|
335
|
+
type(endpoint).__name__,
|
|
336
|
+
)
|
|
337
|
+
models.append(
|
|
338
|
+
types.ModelTarget(
|
|
339
|
+
name=image_model,
|
|
340
|
+
types=[types.ModelType.IMAGE],
|
|
341
|
+
endpoint=endpoint,
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
if model:
|
|
346
|
+
logger.info("应用主模型配置: %s", model)
|
|
347
|
+
|
|
348
|
+
config = LocalAgentConfig(
|
|
349
|
+
model=model,
|
|
350
|
+
models=models if models else None,
|
|
351
|
+
system_instructions=SYSTEM_INSTRUCTIONS,
|
|
352
|
+
policies=[policy.allow_all()],
|
|
353
|
+
workspaces=[resolved_work_dir],
|
|
354
|
+
mcp_servers=mcp_servers,
|
|
355
|
+
skills_paths=effective_skills_paths if effective_skills_paths else None,
|
|
356
|
+
)
|
|
357
|
+
return Agent(config)
|
|
358
|
+
|
|
359
|
+
def get_lock(
|
|
360
|
+
self,
|
|
361
|
+
session_key: str | None = None,
|
|
362
|
+
chat_id: str | None = None,
|
|
363
|
+
) -> asyncio.Lock:
|
|
364
|
+
"""获取会话级并发互斥锁,确保同一 session_key 消息串行处理。
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
session_key: 会话标识符,格式通常为 f"{channel}:{chat_id}"。
|
|
368
|
+
chat_id: 兼容历史调用的参数别名。
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
对应会话的 asyncio.Lock 互斥锁。
|
|
372
|
+
"""
|
|
373
|
+
key = session_key if session_key is not None else chat_id
|
|
374
|
+
if key is None:
|
|
375
|
+
raise ValueError("必须提供 session_key 或 chat_id")
|
|
376
|
+
if key not in self._locks:
|
|
377
|
+
self._locks[key] = asyncio.Lock()
|
|
378
|
+
return self._locks[key]
|
|
379
|
+
|
|
380
|
+
async def get_or_create_agent(
|
|
381
|
+
self,
|
|
382
|
+
session_key: str | None = None,
|
|
383
|
+
chat_id: str | None = None,
|
|
384
|
+
) -> Agent:
|
|
385
|
+
"""获取或初始化会话的 Agent 实例,自动将其上下文托管给 exit_stack。
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
session_key: 会话标识符,格式通常为 f"{channel}:{chat_id}"。
|
|
389
|
+
chat_id: 兼容历史调用的参数别名。
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
托管后的 Agent 实例。
|
|
393
|
+
"""
|
|
394
|
+
key = session_key if session_key is not None else chat_id
|
|
395
|
+
if key is None:
|
|
396
|
+
raise ValueError("必须提供 session_key 或 chat_id")
|
|
397
|
+
if key not in self._sessions:
|
|
398
|
+
logger.info("首次为会话 %s 创建 Agent 实例...", key)
|
|
399
|
+
agent = self._agent_factory()
|
|
400
|
+
if hasattr(agent, "__aenter__"):
|
|
401
|
+
managed_agent = await self._exit_stack.enter_async_context(agent)
|
|
402
|
+
else:
|
|
403
|
+
managed_agent = agent
|
|
404
|
+
self._sessions[key] = managed_agent
|
|
405
|
+
return self._sessions[key]
|
|
406
|
+
|
|
407
|
+
async def close_all(self) -> None:
|
|
408
|
+
"""退出时统一安全地逆序清理并释放所有 Agent 资源。"""
|
|
409
|
+
logger.info("正在关闭并释放所有 Agent 会话...")
|
|
410
|
+
await self._exit_stack.aclose()
|
|
411
|
+
self._sessions.clear()
|
|
412
|
+
self._locks.clear()
|
|
413
|
+
self._exit_stack = AsyncExitStack()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""通道抽象与平台适配层。
|
|
2
|
+
|
|
3
|
+
提供 BaseBotChannel 通用抽象基类、FeishuBotChannel、DingTalkBotChannel 适配器及 get_channel 工厂函数。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from .base import BaseBotChannel
|
|
12
|
+
from .dingtalk import DingTalkBotChannel
|
|
13
|
+
from .feishu import FeishuBotChannel
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ..agent import AgentSessionManager
|
|
17
|
+
from ..config import Config
|
|
18
|
+
|
|
19
|
+
SUPPORTED_CHANNELS = ("dingtalk", "feishu")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_channel(
|
|
23
|
+
channel_name: str,
|
|
24
|
+
config: Config,
|
|
25
|
+
filter_chat_id: str | None = None,
|
|
26
|
+
session_manager: AgentSessionManager | None = None,
|
|
27
|
+
work_dir: str | Path | None = None,
|
|
28
|
+
**kwargs: Any,
|
|
29
|
+
) -> BaseBotChannel:
|
|
30
|
+
"""根据通道标识获取对应的机器人通道实例。
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
channel_name: 通道名称,支持 "feishu" (别名 "lark") 与 "dingtalk" (别名 "dd")。
|
|
34
|
+
config: 全局配置对象 (包含凭据与模型配置)。
|
|
35
|
+
filter_chat_id: 可选的会话过滤 ID。
|
|
36
|
+
session_manager: 可选的共享 AgentSessionManager 实例。
|
|
37
|
+
work_dir: 可选的工作目录路径。
|
|
38
|
+
**kwargs: 透传给具体通道构造函数的其他参数 (例如 channel mock 实例)。
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
BaseBotChannel 的具体子类实例 (FeishuBotChannel 或 DingTalkBotChannel)。
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ValueError: 当 channel_name 不受支持或为 "all" 时抛出。
|
|
45
|
+
"""
|
|
46
|
+
normalized = (channel_name or "").strip().lower()
|
|
47
|
+
|
|
48
|
+
if normalized == "all":
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"通道 'all' 表示同时运行飞书与钉钉多通道,请分别调用 get_channel 实例化具体通道或使用双通道调度逻辑。"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if normalized in ("feishu", "lark"):
|
|
54
|
+
return FeishuBotChannel(
|
|
55
|
+
config=config,
|
|
56
|
+
filter_chat_id=filter_chat_id,
|
|
57
|
+
session_manager=session_manager,
|
|
58
|
+
work_dir=work_dir,
|
|
59
|
+
**kwargs,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if normalized in ("dingtalk", "dd"):
|
|
63
|
+
return DingTalkBotChannel(
|
|
64
|
+
config=config,
|
|
65
|
+
filter_chat_id=filter_chat_id,
|
|
66
|
+
session_manager=session_manager,
|
|
67
|
+
work_dir=work_dir,
|
|
68
|
+
**kwargs,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"不支持的通道类型: {channel_name!r}。当前仅支持 {SUPPORTED_CHANNELS} (别名: 'lark' / 'dd')。"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"SUPPORTED_CHANNELS",
|
|
78
|
+
"BaseBotChannel",
|
|
79
|
+
"DingTalkBotChannel",
|
|
80
|
+
"FeishuBotChannel",
|
|
81
|
+
"get_channel",
|
|
82
|
+
]
|