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.
Files changed (70) hide show
  1. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/PKG-INFO +6 -2
  2. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/README.md +5 -1
  3. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/__init__.py +1 -1
  4. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/_internal/failover.py +57 -12
  5. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/_internal/pool.py +38 -1
  6. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/agent/errors.py +10 -1
  7. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/agent/loop.py +15 -3
  8. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/agent/mcp.py +11 -1
  9. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/agent/registry.py +6 -2
  10. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/agent/skill.py +6 -3
  11. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/agent/types.py +4 -0
  12. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/chat_service.py +6 -1
  13. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/profiles.py +11 -0
  14. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/.gitignore +0 -0
  15. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/__init__.py +0 -0
  16. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/_internal/__init__.py +0 -0
  17. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/_internal/rerank_transport.py +0 -0
  18. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/_internal/retry_after.py +0 -0
  19. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/_internal/sdk_client.py +0 -0
  20. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/_internal/service_base.py +0 -0
  21. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/_internal/types.py +0 -0
  22. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/agent/__init__.py +0 -0
  23. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/agent/approval.py +0 -0
  24. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/agent/session.py +0 -0
  25. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/decision/__init__.py +0 -0
  26. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/decision/decima.py +0 -0
  27. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/decision/laya.py +0 -0
  28. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/embedding_service.py +0 -0
  29. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/exceptions.py +0 -0
  30. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/image_service.py +0 -0
  31. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/messages.py +0 -0
  32. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/model_builder.py +0 -0
  33. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/model_client.py +0 -0
  34. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/rerank_service.py +0 -0
  35. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/ai/runner.py +0 -0
  36. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/common_kit.py +0 -0
  37. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/config/__init__.py +0 -0
  38. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/config/kit_config.py +0 -0
  39. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/config/log_config.py +0 -0
  40. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/crawl/__init__.py +0 -0
  41. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/crawl/article_io.py +0 -0
  42. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/crawl/article_models.py +0 -0
  43. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/crawl/deepseek_kit.py +0 -0
  44. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/crawl/web_article_kit.py +0 -0
  45. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/crawl/wx_article_kit.py +0 -0
  46. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/__init__.py +0 -0
  47. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/audit.py +0 -0
  48. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/base_db_kit.py +0 -0
  49. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/crud.py +0 -0
  50. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/engine.py +0 -0
  51. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/exceptions.py +0 -0
  52. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/models/__init__.py +0 -0
  53. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/models/base.py +0 -0
  54. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/mysql_kit.py +0 -0
  55. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/pg_kit.py +0 -0
  56. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/query.py +0 -0
  57. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/session.py +0 -0
  58. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/sqlite_kit.py +0 -0
  59. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/db/transaction.py +0 -0
  60. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/docs/__init__.py +0 -0
  61. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/docs/anydoc_kit.py +0 -0
  62. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/docs/docx_kit.py +0 -0
  63. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/docs/marker_kit.py +0 -0
  64. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/docs/md2doc_kit.py +0 -0
  65. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/docs/mineru_kit.py +0 -0
  66. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/docs/rc_docparser_kit.py +0 -0
  67. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/file_kit.py +0 -0
  68. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/license_kit.py +0 -0
  69. {sdpy_kit-0.8.1 → sdpy_kit-0.8.2}/kit/redis_kit.py +0 -0
  70. {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.1
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
- - `timeout` / `max_turns` / `tool_timeout` 控制总预算与轮次;
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
- - `timeout` / `max_turns` / `tool_timeout` 控制总预算与轮次;
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)
@@ -23,7 +23,7 @@ Author: Clark Chang
23
23
  数据库访问通过 kit.db 子包提供(基于 SQLModel)。
24
24
  """
25
25
 
26
- __version__ = "0.8.1"
26
+ __version__ = "0.8.2"
27
27
 
28
28
  from kit.db import (
29
29
  MYSQL,
@@ -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(self._model_type):
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
- return await cast(ChatService, service).create_with_tools(
235
- messages,
236
- tools,
237
- tool_choice=tool_choice,
238
- temperature=temperature,
239
- max_tokens=max_tokens,
240
- thinking=thinking,
241
- stats=stats,
242
- deadline=deadline,
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("工具对话", _run, deadline=deadline)
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(self, model_type: str) -> Iterator[tuple[str, Service]]:
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__(self, max_turns: int, turns: int | None = None) -> None:
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
- if not await self._approval(request):
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(f"MCP server {self._config.name!r} 关闭失败: {exc}")
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
- if name in self._tools and not replace:
127
- raise ValueError(f"工具名重复: {name}")
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
- if package.name in self._packages:
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"(已加载 {self._packages[package.name].root},"
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