sdpy-kit 0.8.1__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.1 → sdpy_kit-0.8.2}/PKG-INFO +6 -2
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/README.md +5 -1
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/__init__.py +1 -1
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/_internal/failover.py +57 -12
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/_internal/pool.py +38 -1
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/agent/errors.py +10 -1
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/agent/loop.py +15 -3
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/agent/mcp.py +11 -1
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/agent/registry.py +6 -2
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/agent/skill.py +6 -3
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/agent/types.py +4 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/chat_service.py +6 -1
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/profiles.py +11 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/.gitignore +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/__init__.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/_internal/__init__.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/_internal/rerank_transport.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/_internal/retry_after.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/_internal/sdk_client.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/_internal/service_base.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/_internal/types.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/agent/__init__.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/agent/approval.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/agent/session.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/decision/__init__.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/decision/decima.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/decision/laya.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/embedding_service.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/exceptions.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/image_service.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/messages.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/model_builder.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/model_client.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/rerank_service.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/runner.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/common_kit.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/config/__init__.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/config/kit_config.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/config/log_config.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/crawl/__init__.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/crawl/article_io.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/crawl/article_models.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/crawl/deepseek_kit.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/crawl/web_article_kit.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/crawl/wx_article_kit.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/__init__.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/audit.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/base_db_kit.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/crud.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/engine.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/exceptions.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/models/__init__.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/models/base.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/mysql_kit.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/pg_kit.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/query.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/session.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/sqlite_kit.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/transaction.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/docs/__init__.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/docs/anydoc_kit.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/docs/docx_kit.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/docs/marker_kit.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/docs/md2doc_kit.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/docs/mineru_kit.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/docs/rc_docparser_kit.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/file_kit.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/license_kit.py +0 -0
- {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/redis_kit.py +0 -0
- {sdpy_kit-0.8.1 → 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,6 +120,8 @@ 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
127
|
self._tools: ToolRegistry = tools if tools is not None else ToolRegistry()
|
|
@@ -128,6 +131,7 @@ class Agent:
|
|
|
128
131
|
self._approval: ApprovalHook = (
|
|
129
132
|
approval if approval is not None else DefaultApproval()
|
|
130
133
|
)
|
|
134
|
+
self._approval_timeout: float = approval_timeout
|
|
131
135
|
self._max_turns: int = max_turns
|
|
132
136
|
self._timeout: float = timeout
|
|
133
137
|
self._tool_timeout: float = tool_timeout
|
|
@@ -310,7 +314,7 @@ class Agent:
|
|
|
310
314
|
|
|
311
315
|
if turn >= self._max_turns:
|
|
312
316
|
# 最后一轮不再执行工具:没有余量回灌结果,避免无意义副作用
|
|
313
|
-
raise MaxTurnsExceeded(self._max_turns, turn)
|
|
317
|
+
raise MaxTurnsExceeded(self._max_turns, turn, records)
|
|
314
318
|
self._append_assistant(prepared, sink, reply)
|
|
315
319
|
for call in reply.tool_calls:
|
|
316
320
|
try:
|
|
@@ -331,7 +335,7 @@ class Agent:
|
|
|
331
335
|
if sink is not None:
|
|
332
336
|
sink.add_tool(call["id"], record.result.content)
|
|
333
337
|
|
|
334
|
-
raise MaxTurnsExceeded(self._max_turns, turns)
|
|
338
|
+
raise MaxTurnsExceeded(self._max_turns, turns, records)
|
|
335
339
|
|
|
336
340
|
async def run_once(
|
|
337
341
|
self,
|
|
@@ -384,6 +388,7 @@ class Agent:
|
|
|
384
388
|
session=self._session,
|
|
385
389
|
system_prompt=self._system_prompt,
|
|
386
390
|
approval=self._approval,
|
|
391
|
+
approval_timeout=self._approval_timeout,
|
|
387
392
|
max_turns=self._max_turns,
|
|
388
393
|
timeout=self._timeout,
|
|
389
394
|
tool_timeout=self._tool_timeout,
|
|
@@ -592,7 +597,14 @@ class Agent:
|
|
|
592
597
|
target=entry.approval_target or name,
|
|
593
598
|
arguments=arguments,
|
|
594
599
|
)
|
|
595
|
-
|
|
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:
|
|
596
608
|
raise ApprovalDeniedError(f"审批拒绝: {request.target}")
|
|
597
609
|
|
|
598
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,
|
|
@@ -195,7 +196,10 @@ class McpConnection:
|
|
|
195
196
|
try:
|
|
196
197
|
await stack.aclose()
|
|
197
198
|
except Exception as exc: # noqa: BLE001 - 关闭阶段失败不影响退出
|
|
198
|
-
logger.warning(
|
|
199
|
+
logger.warning(
|
|
200
|
+
f"MCP server {self._config.name!r} 关闭失败: "
|
|
201
|
+
f"{type(exc).__name__}: {exc}"
|
|
202
|
+
)
|
|
199
203
|
|
|
200
204
|
async def __aenter__(self) -> "McpConnection":
|
|
201
205
|
await self.open()
|
|
@@ -261,6 +265,12 @@ class McpConnection:
|
|
|
261
265
|
is_error=True,
|
|
262
266
|
)
|
|
263
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
|
+
)
|
|
264
274
|
raise ToolExecutionError(
|
|
265
275
|
f"MCP 工具 {self._config.name}.{raw_name} 调用失败: "
|
|
266
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
|
|
@@ -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
|