sdpy-kit 0.8.0__tar.gz → 0.8.2__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.
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/PKG-INFO +6 -2
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/README.md +5 -1
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/__init__.py +1 -1
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/_internal/failover.py +57 -12
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/_internal/pool.py +38 -1
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/agent/errors.py +10 -1
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/agent/loop.py +30 -9
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/agent/mcp.py +14 -2
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/agent/registry.py +6 -2
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/agent/skill.py +12 -4
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/agent/types.py +4 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/chat_service.py +6 -1
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/profiles.py +11 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/.gitignore +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/__init__.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/_internal/__init__.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/_internal/rerank_transport.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/_internal/retry_after.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/_internal/sdk_client.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/_internal/service_base.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/_internal/types.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/agent/__init__.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/agent/approval.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/agent/session.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/decision/__init__.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/decision/decima.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/decision/laya.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/embedding_service.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/exceptions.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/image_service.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/messages.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/model_builder.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/model_client.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/rerank_service.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/runner.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/common_kit.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/config/__init__.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/config/kit_config.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/config/log_config.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/crawl/__init__.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/crawl/article_io.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/crawl/article_models.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/crawl/deepseek_kit.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/crawl/web_article_kit.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/crawl/wx_article_kit.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/__init__.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/audit.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/base_db_kit.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/crud.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/engine.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/exceptions.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/models/__init__.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/models/base.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/mysql_kit.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/pg_kit.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/query.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/session.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/sqlite_kit.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/transaction.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/docs/__init__.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/docs/anydoc_kit.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/docs/docx_kit.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/docs/marker_kit.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/docs/md2doc_kit.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/docs/mineru_kit.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/docs/rc_docparser_kit.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/file_kit.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/license_kit.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/redis_kit.py +0 -0
- {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sdpy-kit
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.2
|
|
4
4
|
Summary: SDPY Kit——统一数据库访问层(PG/MySQL/SQLite)与 AI/Redis/文件/文档/向量工具集
|
|
5
5
|
Author-email: Clark <changhongyuan@126.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -96,6 +96,8 @@ SDPY 统一工具集(Kit 体系),供 Python 项目开箱即用:
|
|
|
96
96
|
|
|
97
97
|
> 要求 Python 3.13+
|
|
98
98
|
|
|
99
|
+
> **版本提示**:`0.8.0` 存在空 `ToolRegistry` / 空 `Session` 被真值判断替换的缺陷,请使用 `0.8.1+`(当前推荐 `0.8.2`)。
|
|
100
|
+
|
|
99
101
|
## 模块与方法入口
|
|
100
102
|
|
|
101
103
|
| 模块 | 类 / 入口 | 能力 | 需要 extra |
|
|
@@ -184,9 +186,11 @@ asyncio.run(main())
|
|
|
184
186
|
```
|
|
185
187
|
|
|
186
188
|
- 模型档案沿用 `kit.ai`(`config/ai_models.json`),首轮可 failover,成功后锁定档案;
|
|
189
|
+
- 档案可在 `ai_models.json` 写 `supports_tools: true/false` 声明 function calling 能力;缺省时运行时检测 400 并自动换候选;
|
|
187
190
|
- Skill 只读工具:`read_skill` / `list_skill_files` / `read_skill_file`(路径越界拒绝);
|
|
188
191
|
- MCP 工具默认放行,skill 脚本默认拒绝;可用 `DenyAllApproval` 整体收紧;
|
|
189
|
-
-
|
|
192
|
+
- stdio 默认 `keep_alive=False`,`Agent` 退出即断开并终止直接子进程;
|
|
193
|
+
- `timeout` / `max_turns` / `tool_timeout` / `approval_timeout` 控制总预算、轮次与审批等待;
|
|
190
194
|
- 完整说明见 `docs/agents/modules/agent-kit.md`(仓库内)。
|
|
191
195
|
|
|
192
196
|
## 配置(.env)
|
|
@@ -8,6 +8,8 @@ SDPY 统一工具集(Kit 体系),供 Python 项目开箱即用:
|
|
|
8
8
|
|
|
9
9
|
> 要求 Python 3.13+
|
|
10
10
|
|
|
11
|
+
> **版本提示**:`0.8.0` 存在空 `ToolRegistry` / 空 `Session` 被真值判断替换的缺陷,请使用 `0.8.1+`(当前推荐 `0.8.2`)。
|
|
12
|
+
|
|
11
13
|
## 模块与方法入口
|
|
12
14
|
|
|
13
15
|
| 模块 | 类 / 入口 | 能力 | 需要 extra |
|
|
@@ -96,9 +98,11 @@ asyncio.run(main())
|
|
|
96
98
|
```
|
|
97
99
|
|
|
98
100
|
- 模型档案沿用 `kit.ai`(`config/ai_models.json`),首轮可 failover,成功后锁定档案;
|
|
101
|
+
- 档案可在 `ai_models.json` 写 `supports_tools: true/false` 声明 function calling 能力;缺省时运行时检测 400 并自动换候选;
|
|
99
102
|
- Skill 只读工具:`read_skill` / `list_skill_files` / `read_skill_file`(路径越界拒绝);
|
|
100
103
|
- MCP 工具默认放行,skill 脚本默认拒绝;可用 `DenyAllApproval` 整体收紧;
|
|
101
|
-
-
|
|
104
|
+
- stdio 默认 `keep_alive=False`,`Agent` 退出即断开并终止直接子进程;
|
|
105
|
+
- `timeout` / `max_turns` / `tool_timeout` / `approval_timeout` 控制总预算、轮次与审批等待;
|
|
102
106
|
- 完整说明见 `docs/agents/modules/agent-kit.md`(仓库内)。
|
|
103
107
|
|
|
104
108
|
## 配置(.env)
|
|
@@ -69,6 +69,34 @@ _EMBED_CONCURRENCY = 4
|
|
|
69
69
|
# 不改变超时时长语义(推理帧被吞掉,不轮询就看不到思考阶段的活动)
|
|
70
70
|
_FIRST_FRAME_POLL: Final[float] = 1.0
|
|
71
71
|
|
|
72
|
+
# 运行时判定「档案不支持 function calling」的关键词(仅 400 时匹配):
|
|
73
|
+
# 必须同时命中「工具词」+「不支持/未识别词」,避免把参数错误误判为能力缺失
|
|
74
|
+
_TOOL_ERROR_TERMS: Final[tuple[str, ...]] = ("tool", "function")
|
|
75
|
+
_UNSUPPORTED_TERMS: Final[tuple[str, ...]] = (
|
|
76
|
+
"not supported",
|
|
77
|
+
"unsupported",
|
|
78
|
+
"not support",
|
|
79
|
+
"does not support",
|
|
80
|
+
"not enabled",
|
|
81
|
+
"unrecognized",
|
|
82
|
+
"unknown parameter",
|
|
83
|
+
"unknown field",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _looks_like_tools_unsupported(exc: AIError) -> bool:
|
|
88
|
+
"""判断 400 错误是否由 tools / function calling 不被支持引起。
|
|
89
|
+
|
|
90
|
+
仅匹配「工具词 + 不支持词」组合,未知错误一律不标记,避免把参数错误
|
|
91
|
+
误判为能力缺失。
|
|
92
|
+
"""
|
|
93
|
+
if not isinstance(exc, AIRequestError) or exc.status_code != 400:
|
|
94
|
+
return False
|
|
95
|
+
message = str(exc).lower()
|
|
96
|
+
has_tool = any(term in message for term in _TOOL_ERROR_TERMS)
|
|
97
|
+
has_unsupported = any(term in message for term in _UNSUPPORTED_TERMS)
|
|
98
|
+
return has_tool and has_unsupported
|
|
99
|
+
|
|
72
100
|
|
|
73
101
|
class _FailoverBase:
|
|
74
102
|
"""链式调用基类:候选迭代 + 失败冷却 + 整链预算 + 聚合异常。"""
|
|
@@ -133,6 +161,7 @@ class _FailoverBase:
|
|
|
133
161
|
run: Callable[[Service], Awaitable[T]],
|
|
134
162
|
*,
|
|
135
163
|
deadline: float | None = None,
|
|
164
|
+
require_tools: bool = False,
|
|
136
165
|
) -> T:
|
|
137
166
|
"""按候选顺序执行 ``run``;单档案失败冷却后换下一个,全失败聚合抛出。
|
|
138
167
|
|
|
@@ -140,11 +169,15 @@ class _FailoverBase:
|
|
|
140
169
|
deadline: 整链预算截止时刻(``time.monotonic()`` 秒);每个候选
|
|
141
170
|
发起前检查,预算耗尽则不再发起新候选,改抛携带「预算耗尽」
|
|
142
171
|
语义的聚合异常(避免门面从外部 cancel 使备胎白等)
|
|
172
|
+
require_tools: True 时跳过声明/运行时判定不支持 function calling
|
|
173
|
+
的档案(工具循环专用)
|
|
143
174
|
"""
|
|
144
175
|
tried: list[str] = []
|
|
145
176
|
errors: list[AIError] = []
|
|
146
177
|
budget_exhausted = False
|
|
147
|
-
for name, service in self._pool.iter_services(
|
|
178
|
+
for name, service in self._pool.iter_services(
|
|
179
|
+
self._model_type, require_tools=require_tools
|
|
180
|
+
):
|
|
148
181
|
if self._budget_exhausted(deadline):
|
|
149
182
|
budget_exhausted = True
|
|
150
183
|
break
|
|
@@ -231,18 +264,30 @@ class FailoverChatService(_FailoverBase):
|
|
|
231
264
|
async def _run(service: Service) -> ChatReply:
|
|
232
265
|
if stats is not None:
|
|
233
266
|
stats.reset()
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
267
|
+
try:
|
|
268
|
+
return await cast(ChatService, service).create_with_tools(
|
|
269
|
+
messages,
|
|
270
|
+
tools,
|
|
271
|
+
tool_choice=tool_choice,
|
|
272
|
+
temperature=temperature,
|
|
273
|
+
max_tokens=max_tokens,
|
|
274
|
+
thinking=thinking,
|
|
275
|
+
stats=stats,
|
|
276
|
+
deadline=deadline,
|
|
277
|
+
)
|
|
278
|
+
except AIRequestError as exc:
|
|
279
|
+
if _looks_like_tools_unsupported(exc):
|
|
280
|
+
profile = getattr(service, "profile", None)
|
|
281
|
+
name = getattr(profile, "name", None)
|
|
282
|
+
if isinstance(name, str):
|
|
283
|
+
self._pool.mark_tool_unsupported(
|
|
284
|
+
self._model_type, name, exc
|
|
285
|
+
)
|
|
286
|
+
raise
|
|
244
287
|
|
|
245
|
-
return await self._attempt(
|
|
288
|
+
return await self._attempt(
|
|
289
|
+
"工具对话", _run, deadline=deadline, require_tools=True
|
|
290
|
+
)
|
|
246
291
|
|
|
247
292
|
async def create_once(
|
|
248
293
|
self,
|
|
@@ -116,6 +116,7 @@ class _Entry:
|
|
|
116
116
|
"last_error",
|
|
117
117
|
"last_ok_at",
|
|
118
118
|
"service",
|
|
119
|
+
"tool_unsupported",
|
|
119
120
|
)
|
|
120
121
|
|
|
121
122
|
def __init__(self, profile: ModelProfile) -> None:
|
|
@@ -126,6 +127,8 @@ class _Entry:
|
|
|
126
127
|
self.last_error: str | None = None
|
|
127
128
|
self.last_ok_at: float | None = None
|
|
128
129
|
self.service: Service | None = None
|
|
130
|
+
# function calling 能力:声明 False 或运行时 400 判定后为 True
|
|
131
|
+
self.tool_unsupported: bool = profile.supports_tools is False
|
|
129
132
|
|
|
130
133
|
|
|
131
134
|
@dataclass
|
|
@@ -264,9 +267,15 @@ class AIServicePool:
|
|
|
264
267
|
|
|
265
268
|
# ---------- 候选迭代 ----------
|
|
266
269
|
|
|
267
|
-
def iter_services(
|
|
270
|
+
def iter_services(
|
|
271
|
+
self, model_type: str, *, require_tools: bool = False
|
|
272
|
+
) -> Iterator[tuple[str, Service]]:
|
|
268
273
|
"""按竞选顺序迭代(档案名, service):prefer 项打头,跳过冷却档案。
|
|
269
274
|
|
|
275
|
+
Args:
|
|
276
|
+
require_tools: True 时额外跳过声明/运行时判定不支持 function calling
|
|
277
|
+
的档案(Agent 工具循环专用);普通对话不受影响。
|
|
278
|
+
|
|
270
279
|
候选计划(顺序 + prefer 提前 + 冷却到期复位 + 可用性过滤)在生成器
|
|
271
280
|
首次推进时一次定格:此后 ``prefer()`` 切换与其它请求的失败冷却均
|
|
272
281
|
不影响已开始迭代的调用方——切换只为「下一次」请求生效。
|
|
@@ -284,6 +293,8 @@ class AIServicePool:
|
|
|
284
293
|
plan: list[tuple[str, Service | None]] = []
|
|
285
294
|
for name in seq:
|
|
286
295
|
entry = chain.entries[name]
|
|
296
|
+
if require_tools and entry.tool_unsupported:
|
|
297
|
+
continue
|
|
287
298
|
if entry.state is ProfileState.COOLING and now >= entry.cooling_until:
|
|
288
299
|
entry.state = ProfileState.AVAILABLE
|
|
289
300
|
entry.last_error = None
|
|
@@ -341,6 +352,24 @@ class AIServicePool:
|
|
|
341
352
|
entry.last_ok_at = time.time()
|
|
342
353
|
entry.last_error = None
|
|
343
354
|
|
|
355
|
+
def mark_tool_unsupported(
|
|
356
|
+
self, model_type: str, name: str, exc: BaseException
|
|
357
|
+
) -> None:
|
|
358
|
+
"""运行时判定档案不支持 function calling:后续工具调用跳过该档案。
|
|
359
|
+
|
|
360
|
+
只影响 ``require_tools=True`` 的候选迭代;普通对话仍可使用该档案。
|
|
361
|
+
"""
|
|
362
|
+
with self._lock:
|
|
363
|
+
chain = self._chains.get(model_type)
|
|
364
|
+
entry = chain.entries.get(name) if chain else None
|
|
365
|
+
if entry is None:
|
|
366
|
+
return
|
|
367
|
+
entry.tool_unsupported = True
|
|
368
|
+
logger.warning(
|
|
369
|
+
f"{model_type}.{name} 判定不支持 function calling,"
|
|
370
|
+
f"后续工具调用将跳过: {exc}"
|
|
371
|
+
)
|
|
372
|
+
|
|
344
373
|
# ---------- 当前项与状态 ----------
|
|
345
374
|
|
|
346
375
|
def prefer(self, model_type: str, profile: str) -> None:
|
|
@@ -406,6 +435,11 @@ class AIServicePool:
|
|
|
406
435
|
last_ok_at=entry.last_ok_at,
|
|
407
436
|
thinking_switchable=entry.profile.thinking is not None,
|
|
408
437
|
json_mode_supported=entry.profile.structured_output is not None,
|
|
438
|
+
tools_supported=(
|
|
439
|
+
False
|
|
440
|
+
if entry.tool_unsupported
|
|
441
|
+
else entry.profile.supports_tools
|
|
442
|
+
),
|
|
409
443
|
)
|
|
410
444
|
for name, entry in chain.entries.items()
|
|
411
445
|
]
|
|
@@ -426,6 +460,9 @@ class AIServicePool:
|
|
|
426
460
|
model_type == "chat"
|
|
427
461
|
and config.structured_output is not False
|
|
428
462
|
),
|
|
463
|
+
tools_supported=(
|
|
464
|
+
config.supports_tools if model_type == "chat" else None
|
|
465
|
+
),
|
|
429
466
|
)
|
|
430
467
|
)
|
|
431
468
|
return rows
|
|
@@ -20,6 +20,8 @@ Author: Clark Chang
|
|
|
20
20
|
# date: 2026-11-10
|
|
21
21
|
"""kit.ai.agent 异常体系(统一继承 AgentError)。"""
|
|
22
22
|
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
23
25
|
__all__ = [
|
|
24
26
|
"AgentConfigError",
|
|
25
27
|
"AgentEmptyReplyError",
|
|
@@ -79,11 +81,18 @@ class MaxTurnsExceeded(AgentError):
|
|
|
79
81
|
Attributes:
|
|
80
82
|
max_turns: 允许的最大轮次。
|
|
81
83
|
turns: 实际执行轮次。
|
|
84
|
+
tool_calls: 已完成执行的工具调用记录(可选,便于调用方决定是否续跑)。
|
|
82
85
|
"""
|
|
83
86
|
|
|
84
|
-
def __init__(
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
max_turns: int,
|
|
90
|
+
turns: int | None = None,
|
|
91
|
+
tool_calls: list[Any] | None = None,
|
|
92
|
+
) -> None:
|
|
85
93
|
self.max_turns: int = max_turns
|
|
86
94
|
self.turns: int = turns if turns is not None else max_turns
|
|
95
|
+
self.tool_calls: list[Any] = list(tool_calls or [])
|
|
87
96
|
super().__init__(f"工具循环超过 {max_turns} 轮仍未结束")
|
|
88
97
|
|
|
89
98
|
|
|
@@ -105,6 +105,7 @@ class Agent:
|
|
|
105
105
|
session: Session | None = None,
|
|
106
106
|
system_prompt: str | None = None,
|
|
107
107
|
approval: ApprovalHook | None = None,
|
|
108
|
+
approval_timeout: float = 30.0,
|
|
108
109
|
max_turns: int = 8,
|
|
109
110
|
timeout: float = 120.0,
|
|
110
111
|
tool_timeout: float = 30.0,
|
|
@@ -119,13 +120,18 @@ class Agent:
|
|
|
119
120
|
raise ValueError("timeout 必须 > 0")
|
|
120
121
|
if tool_timeout <= 0:
|
|
121
122
|
raise ValueError("tool_timeout 必须 > 0")
|
|
123
|
+
if approval_timeout <= 0:
|
|
124
|
+
raise ValueError("approval_timeout 必须 > 0")
|
|
122
125
|
if max_tool_result_chars < 1:
|
|
123
126
|
raise ValueError("max_tool_result_chars 必须 >= 1")
|
|
124
|
-
self._tools: ToolRegistry = tools
|
|
127
|
+
self._tools: ToolRegistry = tools if tools is not None else ToolRegistry()
|
|
125
128
|
self._skills: SkillRegistry | None = skills
|
|
126
129
|
self._servers: tuple[McpServerConfig, ...] = tuple(servers)
|
|
127
130
|
self._system_prompt: str | None = system_prompt or None
|
|
128
|
-
self._approval: ApprovalHook =
|
|
131
|
+
self._approval: ApprovalHook = (
|
|
132
|
+
approval if approval is not None else DefaultApproval()
|
|
133
|
+
)
|
|
134
|
+
self._approval_timeout: float = approval_timeout
|
|
129
135
|
self._max_turns: int = max_turns
|
|
130
136
|
self._timeout: float = timeout
|
|
131
137
|
self._tool_timeout: float = tool_timeout
|
|
@@ -133,7 +139,11 @@ class Agent:
|
|
|
133
139
|
self._chat: ChatLike | None = chat
|
|
134
140
|
self._chat_factory: Callable[[str], ChatLike] | None = chat_factory
|
|
135
141
|
self._model: str | None = model
|
|
136
|
-
self._session: Session =
|
|
142
|
+
self._session: Session = (
|
|
143
|
+
session
|
|
144
|
+
if session is not None
|
|
145
|
+
else Session(self._compose_system_prompt())
|
|
146
|
+
)
|
|
137
147
|
|
|
138
148
|
self._merged: ToolRegistry | None = None
|
|
139
149
|
self._mcp: list[Any] = []
|
|
@@ -302,10 +312,10 @@ class Agent:
|
|
|
302
312
|
stop_reason="completed",
|
|
303
313
|
)
|
|
304
314
|
|
|
305
|
-
self._append_assistant(prepared, sink, reply)
|
|
306
315
|
if turn >= self._max_turns:
|
|
307
316
|
# 最后一轮不再执行工具:没有余量回灌结果,避免无意义副作用
|
|
308
|
-
raise MaxTurnsExceeded(self._max_turns, turn)
|
|
317
|
+
raise MaxTurnsExceeded(self._max_turns, turn, records)
|
|
318
|
+
self._append_assistant(prepared, sink, reply)
|
|
309
319
|
for call in reply.tool_calls:
|
|
310
320
|
try:
|
|
311
321
|
record = await self._dispatch_tool_call(call, deadline, turn)
|
|
@@ -325,7 +335,7 @@ class Agent:
|
|
|
325
335
|
if sink is not None:
|
|
326
336
|
sink.add_tool(call["id"], record.result.content)
|
|
327
337
|
|
|
328
|
-
raise MaxTurnsExceeded(self._max_turns, turns)
|
|
338
|
+
raise MaxTurnsExceeded(self._max_turns, turns, records)
|
|
329
339
|
|
|
330
340
|
async def run_once(
|
|
331
341
|
self,
|
|
@@ -378,6 +388,7 @@ class Agent:
|
|
|
378
388
|
session=self._session,
|
|
379
389
|
system_prompt=self._system_prompt,
|
|
380
390
|
approval=self._approval,
|
|
391
|
+
approval_timeout=self._approval_timeout,
|
|
381
392
|
max_turns=self._max_turns,
|
|
382
393
|
timeout=self._timeout,
|
|
383
394
|
tool_timeout=self._tool_timeout,
|
|
@@ -409,7 +420,7 @@ class Agent:
|
|
|
409
420
|
prepared = self._validate_messages(messages)
|
|
410
421
|
sink: Session | None = None
|
|
411
422
|
else:
|
|
412
|
-
target = session
|
|
423
|
+
target = session if session is not None else self._session
|
|
413
424
|
prepared = self._validate_messages(target.messages())
|
|
414
425
|
sink = target
|
|
415
426
|
index = self._skills.index_block() if self._skills is not None else ""
|
|
@@ -524,7 +535,10 @@ class Agent:
|
|
|
524
535
|
profile = getattr(source, "last_profile", None)
|
|
525
536
|
if not isinstance(profile, str) or not profile:
|
|
526
537
|
return
|
|
527
|
-
|
|
538
|
+
try:
|
|
539
|
+
self._locked_chat = self._build_locked_chat(profile)
|
|
540
|
+
except AIError as exc:
|
|
541
|
+
raise AgentModelError(f"锁定档案 {profile!r} 失败: {exc}") from exc
|
|
528
542
|
logger.info(f"工具循环已锁定档案: {profile}")
|
|
529
543
|
|
|
530
544
|
def _build_locked_chat(self, profile: str) -> ChatLike:
|
|
@@ -583,7 +597,14 @@ class Agent:
|
|
|
583
597
|
target=entry.approval_target or name,
|
|
584
598
|
arguments=arguments,
|
|
585
599
|
)
|
|
586
|
-
|
|
600
|
+
try:
|
|
601
|
+
async with asyncio.timeout(self._approval_timeout):
|
|
602
|
+
approved = await self._approval(request)
|
|
603
|
+
except TimeoutError as exc:
|
|
604
|
+
raise ApprovalDeniedError(
|
|
605
|
+
f"审批超时({self._approval_timeout:g}s): {request.target}"
|
|
606
|
+
) from exc
|
|
607
|
+
if not approved:
|
|
587
608
|
raise ApprovalDeniedError(f"审批拒绝: {request.target}")
|
|
588
609
|
|
|
589
610
|
remaining = deadline - time.monotonic()
|
|
@@ -146,6 +146,7 @@ class McpConnection:
|
|
|
146
146
|
args=list(config.args),
|
|
147
147
|
env=config.env,
|
|
148
148
|
cwd=config.cwd,
|
|
149
|
+
keep_alive=config.keep_alive,
|
|
149
150
|
)
|
|
150
151
|
return Client(
|
|
151
152
|
transport,
|
|
@@ -162,9 +163,11 @@ class McpConnection:
|
|
|
162
163
|
"""
|
|
163
164
|
if self._stack is not None:
|
|
164
165
|
return
|
|
165
|
-
client = self._client if self._client is not None else self._build_client()
|
|
166
166
|
stack = AsyncExitStack()
|
|
167
167
|
try:
|
|
168
|
+
client = (
|
|
169
|
+
self._client if self._client is not None else self._build_client()
|
|
170
|
+
)
|
|
168
171
|
async with asyncio.timeout(self._config.connect_timeout):
|
|
169
172
|
await stack.enter_async_context(client)
|
|
170
173
|
self._tools = list(await client.list_tools())
|
|
@@ -193,7 +196,10 @@ class McpConnection:
|
|
|
193
196
|
try:
|
|
194
197
|
await stack.aclose()
|
|
195
198
|
except Exception as exc: # noqa: BLE001 - 关闭阶段失败不影响退出
|
|
196
|
-
logger.warning(
|
|
199
|
+
logger.warning(
|
|
200
|
+
f"MCP server {self._config.name!r} 关闭失败: "
|
|
201
|
+
f"{type(exc).__name__}: {exc}"
|
|
202
|
+
)
|
|
197
203
|
|
|
198
204
|
async def __aenter__(self) -> "McpConnection":
|
|
199
205
|
await self.open()
|
|
@@ -259,6 +265,12 @@ class McpConnection:
|
|
|
259
265
|
is_error=True,
|
|
260
266
|
)
|
|
261
267
|
except Exception as exc: # noqa: BLE001 - 协议异常统一转为工具错误
|
|
268
|
+
message = str(exc).lower()
|
|
269
|
+
if "timed out" in message:
|
|
270
|
+
return ToolResult(
|
|
271
|
+
content=f"[timeout after {self._config.call_timeout:g}s]",
|
|
272
|
+
is_error=True,
|
|
273
|
+
)
|
|
262
274
|
raise ToolExecutionError(
|
|
263
275
|
f"MCP 工具 {self._config.name}.{raw_name} 调用失败: "
|
|
264
276
|
f"{type(exc).__name__}: {exc}"
|
|
@@ -123,8 +123,12 @@ class ToolRegistry:
|
|
|
123
123
|
ValueError: 名称重复且未指定 ``replace=True``。
|
|
124
124
|
"""
|
|
125
125
|
name = sanitize_tool_name(spec.name)
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
existing = self._tools.get(name)
|
|
127
|
+
if existing is not None and not replace:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"工具名重复: {name}"
|
|
130
|
+
f"(已有来源 {existing.kind},本次来源 {kind})"
|
|
131
|
+
)
|
|
128
132
|
schema = clean_schema(spec.input_schema)
|
|
129
133
|
if schema is None:
|
|
130
134
|
logger.warning(
|
|
@@ -114,11 +114,14 @@ class SkillRegistry:
|
|
|
114
114
|
if not skill_file.is_file():
|
|
115
115
|
continue
|
|
116
116
|
package = _parse_package(child, skill_file)
|
|
117
|
-
|
|
117
|
+
existing = self._packages.get(package.name)
|
|
118
|
+
if existing is not None:
|
|
119
|
+
if existing.root.resolve() == package.root.resolve():
|
|
120
|
+
# 同一目录重复 load:幂等跳过,便于配置热重载
|
|
121
|
+
continue
|
|
118
122
|
raise DuplicateSkillError(
|
|
119
123
|
f"技能重名: {package.name}"
|
|
120
|
-
f"(已加载 {
|
|
121
|
-
f"本次 {package.root})"
|
|
124
|
+
f"(已加载 {existing.root},本次 {package.root})"
|
|
122
125
|
)
|
|
123
126
|
self._packages[package.name] = package
|
|
124
127
|
added += 1
|
|
@@ -355,7 +358,12 @@ def _parse_script(
|
|
|
355
358
|
f"{skill_file} 的脚本 {name!r} 缺少合法 args"
|
|
356
359
|
"(必须是 type=object 的 JSON Schema)"
|
|
357
360
|
)
|
|
358
|
-
|
|
361
|
+
try:
|
|
362
|
+
_resolve_within(root, path)
|
|
363
|
+
except ToolExecutionError as exc:
|
|
364
|
+
raise SkillValidationError(
|
|
365
|
+
f"{skill_file} 的脚本 {name!r} 路径非法: {exc}"
|
|
366
|
+
) from exc
|
|
359
367
|
description = item.get("description", "")
|
|
360
368
|
if not isinstance(description, str):
|
|
361
369
|
description = str(description)
|
|
@@ -98,6 +98,9 @@ class McpServerConfig:
|
|
|
98
98
|
call_timeout: float = 60.0
|
|
99
99
|
tool_prefix: bool = True
|
|
100
100
|
optional: bool = False
|
|
101
|
+
# stdio 子进程是否跨连接保活。默认 False:close() 即断开并终止直接子进程;
|
|
102
|
+
# 设为 True 可复用子进程,但 Agent 退出后可能残留,需调用方自行管理。
|
|
103
|
+
keep_alive: bool = False
|
|
101
104
|
|
|
102
105
|
def __post_init__(self) -> None:
|
|
103
106
|
if not self.name or not self.name.strip():
|
|
@@ -131,6 +134,7 @@ class McpServerConfig:
|
|
|
131
134
|
"call_timeout",
|
|
132
135
|
"tool_prefix",
|
|
133
136
|
"optional",
|
|
137
|
+
"keep_alive",
|
|
134
138
|
}
|
|
135
139
|
unknown = sorted(set(data) - allowed)
|
|
136
140
|
if unknown:
|
|
@@ -30,7 +30,7 @@ from typing import Any, ClassVar, Final, Protocol, cast
|
|
|
30
30
|
|
|
31
31
|
from kit.ai._internal.sdk_client import SdkServiceBase
|
|
32
32
|
from kit.ai._internal.types import JSONObject
|
|
33
|
-
from kit.ai.exceptions import AIError, AIResponseError
|
|
33
|
+
from kit.ai.exceptions import AIConfigError, AIError, AIResponseError
|
|
34
34
|
from kit.ai.messages import ChatReply, Message
|
|
35
35
|
from kit.ai.profiles import (
|
|
36
36
|
_RESERVED_PAYLOAD_KEYS,
|
|
@@ -535,6 +535,11 @@ class ChatService(SdkServiceBase):
|
|
|
535
535
|
stats: 收尾统计累加器
|
|
536
536
|
deadline: 整链预算截止时刻(``time.monotonic()`` 秒)
|
|
537
537
|
"""
|
|
538
|
+
if self._profile.supports_tools is False:
|
|
539
|
+
raise AIConfigError(
|
|
540
|
+
f"档案 {self._profile.name} 声明不支持 tools"
|
|
541
|
+
"(supports_tools=false),请改用支持 function calling 的档案"
|
|
542
|
+
)
|
|
538
543
|
request = ChatRequest(
|
|
539
544
|
messages,
|
|
540
545
|
temperature=temperature,
|
|
@@ -134,6 +134,8 @@ class ModelProfile:
|
|
|
134
134
|
None 表示该档案不支持按调用切换思考
|
|
135
135
|
structured_output: 结构化输出的注入声明(逐调用 ``json_mode=`` 据此翻译);
|
|
136
136
|
未声明时取内置协议默认,``None`` 表示档案显式声明不支持
|
|
137
|
+
supports_tools: 是否支持 OpenAI function calling;None 表示未声明
|
|
138
|
+
(按 0.8.1 行为直接尝试),False 表示明确不支持(工具循环跳过该档案)
|
|
137
139
|
"""
|
|
138
140
|
|
|
139
141
|
name: str
|
|
@@ -151,6 +153,7 @@ class ModelProfile:
|
|
|
151
153
|
extra_body: dict[str, Any] | None = None
|
|
152
154
|
thinking: ThinkingSwitchSpec | None = None
|
|
153
155
|
structured_output: StructuredOutputSpec | None = None
|
|
156
|
+
supports_tools: bool | None = None
|
|
154
157
|
|
|
155
158
|
|
|
156
159
|
class ProfileState(enum.Enum):
|
|
@@ -178,6 +181,8 @@ class ProfileStatus:
|
|
|
178
181
|
thinking_switchable: 该档案是否声明了思考开关(可逐调用关/开思考)
|
|
179
182
|
json_mode_supported: 该档案是否支持结构化输出(仅显式 ``false`` 为 False;
|
|
180
183
|
未声明按内置协议默认,视为支持)
|
|
184
|
+
tools_supported: 是否支持 function calling;None=未声明/未探测,
|
|
185
|
+
False=声明或运行时判定不支持
|
|
181
186
|
"""
|
|
182
187
|
|
|
183
188
|
name: str
|
|
@@ -188,6 +193,7 @@ class ProfileStatus:
|
|
|
188
193
|
last_ok_at: float | None = None
|
|
189
194
|
thinking_switchable: bool = False
|
|
190
195
|
json_mode_supported: bool = False
|
|
196
|
+
tools_supported: bool | None = None
|
|
191
197
|
|
|
192
198
|
|
|
193
199
|
class ThinkingSwitchSpecModel(BaseModel):
|
|
@@ -303,6 +309,9 @@ class ProfileConfig(BaseModel):
|
|
|
303
309
|
thinking: ThinkingSwitchSpecModel | None = None
|
|
304
310
|
# 结构化输出注入声明:False = 该档案显式不支持;缺省 = 用内置协议默认
|
|
305
311
|
structured_output: StructuredOutputSpecModel | Literal[False] | None = None
|
|
312
|
+
# function calling 能力声明:False = 明确不支持(工具循环跳过);
|
|
313
|
+
# 缺省 None = 未声明,按 0.8.1 行为直接尝试,由运行时 400 兜底
|
|
314
|
+
supports_tools: bool | None = None
|
|
306
315
|
|
|
307
316
|
|
|
308
317
|
class TypeConfig(BaseModel):
|
|
@@ -466,6 +475,8 @@ class ModelRegistry:
|
|
|
466
475
|
if model_type == "chat"
|
|
467
476
|
else None
|
|
468
477
|
),
|
|
478
|
+
# 工具能力仅对 chat 有意义;其余类型不参与 Agent
|
|
479
|
+
supports_tools=config.supports_tools if model_type == "chat" else None,
|
|
469
480
|
)
|
|
470
481
|
|
|
471
482
|
def get_profile(self, model_type: str, profile: str | None = None) -> ProfileConfig:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|