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.
Files changed (70) hide show
  1. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/PKG-INFO +6 -2
  2. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/README.md +5 -1
  3. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/__init__.py +1 -1
  4. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/_internal/failover.py +57 -12
  5. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/_internal/pool.py +38 -1
  6. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/agent/errors.py +10 -1
  7. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/agent/loop.py +30 -9
  8. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/agent/mcp.py +14 -2
  9. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/agent/registry.py +6 -2
  10. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/agent/skill.py +12 -4
  11. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/agent/types.py +4 -0
  12. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/chat_service.py +6 -1
  13. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/profiles.py +11 -0
  14. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/.gitignore +0 -0
  15. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/__init__.py +0 -0
  16. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/_internal/__init__.py +0 -0
  17. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/_internal/rerank_transport.py +0 -0
  18. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/_internal/retry_after.py +0 -0
  19. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/_internal/sdk_client.py +0 -0
  20. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/_internal/service_base.py +0 -0
  21. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/_internal/types.py +0 -0
  22. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/agent/__init__.py +0 -0
  23. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/agent/approval.py +0 -0
  24. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/agent/session.py +0 -0
  25. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/decision/__init__.py +0 -0
  26. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/decision/decima.py +0 -0
  27. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/decision/laya.py +0 -0
  28. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/embedding_service.py +0 -0
  29. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/exceptions.py +0 -0
  30. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/image_service.py +0 -0
  31. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/messages.py +0 -0
  32. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/model_builder.py +0 -0
  33. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/model_client.py +0 -0
  34. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/rerank_service.py +0 -0
  35. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/ai/runner.py +0 -0
  36. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/common_kit.py +0 -0
  37. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/config/__init__.py +0 -0
  38. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/config/kit_config.py +0 -0
  39. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/config/log_config.py +0 -0
  40. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/crawl/__init__.py +0 -0
  41. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/crawl/article_io.py +0 -0
  42. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/crawl/article_models.py +0 -0
  43. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/crawl/deepseek_kit.py +0 -0
  44. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/crawl/web_article_kit.py +0 -0
  45. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/crawl/wx_article_kit.py +0 -0
  46. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/__init__.py +0 -0
  47. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/audit.py +0 -0
  48. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/base_db_kit.py +0 -0
  49. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/crud.py +0 -0
  50. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/engine.py +0 -0
  51. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/exceptions.py +0 -0
  52. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/models/__init__.py +0 -0
  53. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/models/base.py +0 -0
  54. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/mysql_kit.py +0 -0
  55. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/pg_kit.py +0 -0
  56. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/query.py +0 -0
  57. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/session.py +0 -0
  58. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/sqlite_kit.py +0 -0
  59. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/db/transaction.py +0 -0
  60. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/docs/__init__.py +0 -0
  61. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/docs/anydoc_kit.py +0 -0
  62. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/docs/docx_kit.py +0 -0
  63. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/docs/marker_kit.py +0 -0
  64. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/docs/md2doc_kit.py +0 -0
  65. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/docs/mineru_kit.py +0 -0
  66. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/docs/rc_docparser_kit.py +0 -0
  67. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/file_kit.py +0 -0
  68. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/license_kit.py +0 -0
  69. {sdpy_kit-0.8.0 → sdpy_kit-0.8.2}/kit/redis_kit.py +0 -0
  70. {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.0
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.0"
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,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 or ToolRegistry()
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 = approval or DefaultApproval()
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 = session or Session(self._compose_system_prompt())
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 or self._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
- self._locked_chat = self._build_locked_chat(profile)
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
- 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:
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(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
+ )
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
- 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
@@ -355,7 +358,12 @@ def _parse_script(
355
358
  f"{skill_file} 的脚本 {name!r} 缺少合法 args"
356
359
  "(必须是 type=object 的 JSON Schema)"
357
360
  )
358
- _resolve_within(root, path)
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