zero-agent-core 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. agent_core/__init__.py +525 -0
  2. agent_core/access/__init__.py +6 -0
  3. agent_core/access/context.py +45 -0
  4. agent_core/access/guard.py +40 -0
  5. agent_core/acp/README.md +236 -0
  6. agent_core/acp/__init__.py +20 -0
  7. agent_core/acp/server.py +351 -0
  8. agent_core/acp/types.py +86 -0
  9. agent_core/callbacks/__init__.py +17 -0
  10. agent_core/callbacks/manager.py +105 -0
  11. agent_core/checkpoint/__init__.py +19 -0
  12. agent_core/checkpoint/naming.py +93 -0
  13. agent_core/checkpoint/store.py +186 -0
  14. agent_core/checkpoint/travel.py +448 -0
  15. agent_core/compaction/__init__.py +57 -0
  16. agent_core/compaction/middleware.py +428 -0
  17. agent_core/compaction/prompts.py +32 -0
  18. agent_core/compaction/pruner.py +121 -0
  19. agent_core/compaction/region.py +145 -0
  20. agent_core/compaction/spill.py +81 -0
  21. agent_core/compaction/spill_tool.py +42 -0
  22. agent_core/compaction/summarizer.py +95 -0
  23. agent_core/compaction/token_limit.py +74 -0
  24. agent_core/compaction/types.py +93 -0
  25. agent_core/docs.py +15 -0
  26. agent_core/documents/__init__.py +23 -0
  27. agent_core/documents/loaders.py +79 -0
  28. agent_core/documents/splitters.py +94 -0
  29. agent_core/documents/types.py +197 -0
  30. agent_core/errors.py +81 -0
  31. agent_core/guardrails/__init__.py +19 -0
  32. agent_core/guardrails/core.py +186 -0
  33. agent_core/hitl/__init__.py +15 -0
  34. agent_core/hitl/core.py +517 -0
  35. agent_core/mcp/__init__.py +14 -0
  36. agent_core/mcp/client.py +212 -0
  37. agent_core/mcp/errors.py +26 -0
  38. agent_core/middleware/__init__.py +26 -0
  39. agent_core/middleware/base.py +283 -0
  40. agent_core/model/__init__.py +73 -0
  41. agent_core/model/catalog.py +73 -0
  42. agent_core/model/core.py +179 -0
  43. agent_core/model/governance.py +317 -0
  44. agent_core/model/providers/__init__.py +43 -0
  45. agent_core/model/providers/anthropic.py +294 -0
  46. agent_core/model/providers/common.py +28 -0
  47. agent_core/model/providers/factory.py +107 -0
  48. agent_core/model/providers/gemini.py +273 -0
  49. agent_core/model/providers/ollama.py +80 -0
  50. agent_core/model/providers/openai_compatible.py +526 -0
  51. agent_core/model/structured.py +200 -0
  52. agent_core/observability/__init__.py +21 -0
  53. agent_core/observability/core.py +311 -0
  54. agent_core/ports/__init__.py +31 -0
  55. agent_core/ports/memory.py +130 -0
  56. agent_core/ports/model_selection.py +64 -0
  57. agent_core/ports/storage.py +114 -0
  58. agent_core/prompts/__init__.py +19 -0
  59. agent_core/prompts/registry.py +236 -0
  60. agent_core/prompts/templates.py +166 -0
  61. agent_core/protocol/__init__.py +31 -0
  62. agent_core/protocol/capabilities.py +49 -0
  63. agent_core/protocol/messages.py +100 -0
  64. agent_core/protocol/runtime.py +293 -0
  65. agent_core/py.typed +1 -0
  66. agent_core/retrieval/__init__.py +40 -0
  67. agent_core/retrieval/_common.py +121 -0
  68. agent_core/retrieval/chroma.py +269 -0
  69. agent_core/retrieval/core.py +63 -0
  70. agent_core/retrieval/factory.py +72 -0
  71. agent_core/retrieval/memory.py +232 -0
  72. agent_core/retrieval/pgvector.py +239 -0
  73. agent_core/retrieval/types.py +95 -0
  74. agent_core/runtime/__init__.py +28 -0
  75. agent_core/runtime/compat.py +98 -0
  76. agent_core/runtime/react.py +888 -0
  77. agent_core/runtime/service.py +557 -0
  78. agent_core/runtime/store.py +55 -0
  79. agent_core/serialization/__init__.py +21 -0
  80. agent_core/serialization/registry.py +318 -0
  81. agent_core/skills/__init__.py +24 -0
  82. agent_core/skills/errors.py +27 -0
  83. agent_core/skills/loader.py +258 -0
  84. agent_core/skills/registry.py +205 -0
  85. agent_core/skills/types.py +56 -0
  86. agent_core/storage/__init__.py +107 -0
  87. agent_core/storage/_sqlite_utils.py +92 -0
  88. agent_core/storage/context.py +382 -0
  89. agent_core/storage/lease.py +435 -0
  90. agent_core/storage/memory.py +604 -0
  91. agent_core/storage/model_selection.py +360 -0
  92. agent_core/storage/runtime.py +899 -0
  93. agent_core/storage/session.py +904 -0
  94. agent_core/storage/workflow.py +411 -0
  95. agent_core/tools/__init__.py +5 -0
  96. agent_core/tools/executor.py +306 -0
  97. agent_core/workflow/__init__.py +25 -0
  98. agent_core/workflow/durable.py +175 -0
  99. agent_core/workflow/state_machine.py +427 -0
  100. zero_agent_core-0.2.0.dist-info/METADATA +237 -0
  101. zero_agent_core-0.2.0.dist-info/RECORD +104 -0
  102. zero_agent_core-0.2.0.dist-info/WHEEL +4 -0
  103. zero_agent_core-0.2.0.dist-info/entry_points.txt +3 -0
  104. zero_agent_core-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,318 @@
1
+ """仅允许显式注册类型的 JSON 序列化注册表。
2
+
3
+ 本模块不会根据输入中的 Python 模块或类路径执行动态导入,也不使用 pickle。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import binascii
10
+ import json
11
+ from collections.abc import Callable, Mapping
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ from agent_core.access import AccessContext
16
+ from agent_core.documents import Blob, Document, DocumentChunk, DocumentLocator
17
+ from agent_core.errors import (
18
+ SerializationError,
19
+ UnknownSerializedTypeError,
20
+ UnsupportedSchemaVersionError,
21
+ )
22
+ from agent_core.protocol import ApprovalRecord, Message, RunContext, RunEvent, ToolResult
23
+
24
+ Encoder = Callable[[Any], dict[str, Any]]
25
+ Decoder = Callable[[dict[str, Any]], Any]
26
+ Migration = Callable[[dict[str, Any]], dict[str, Any]]
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class SerializedEnvelope:
31
+ """持久化对象的类型和 Schema 版本信封。"""
32
+
33
+ type_id: str
34
+ schema_version: int
35
+ payload: dict[str, Any]
36
+
37
+ def __post_init__(self) -> None:
38
+ if not self.type_id.strip():
39
+ raise SerializationError("type_id 不能为空")
40
+ if self.schema_version <= 0:
41
+ raise SerializationError("schema_version 必须大于 0")
42
+
43
+ def to_dict(self) -> dict[str, Any]:
44
+ return {
45
+ "type": self.type_id,
46
+ "schema_version": self.schema_version,
47
+ "payload": self.payload,
48
+ }
49
+
50
+ @classmethod
51
+ def from_dict(cls, value: Mapping[str, Any]) -> SerializedEnvelope:
52
+ if not isinstance(value.get("payload"), dict):
53
+ raise SerializationError("序列化 payload 必须是对象")
54
+ try:
55
+ return cls(
56
+ type_id=str(value["type"]),
57
+ schema_version=int(value["schema_version"]),
58
+ payload=dict(value["payload"]),
59
+ )
60
+ except (KeyError, TypeError, ValueError) as exc:
61
+ raise SerializationError("序列化信封缺少有效的 type 或 schema_version") from exc
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class _Codec:
66
+ type_id: str
67
+ python_type: type[Any]
68
+ schema_version: int
69
+ encoder: Encoder
70
+ decoder: Decoder
71
+ migrations: dict[int, Migration] = field(default_factory=dict)
72
+
73
+
74
+ class SerializerRegistry:
75
+ """显式白名单注册表,负责版本迁移和对象重建。"""
76
+
77
+ def __init__(self) -> None:
78
+ self._by_type_id: dict[str, _Codec] = {}
79
+ self._by_python_type: dict[type[Any], _Codec] = {}
80
+
81
+ def register(
82
+ self,
83
+ type_id: str,
84
+ python_type: type[Any],
85
+ *,
86
+ schema_version: int,
87
+ encoder: Encoder,
88
+ decoder: Decoder,
89
+ migrations: Mapping[int, Migration] | None = None,
90
+ ) -> None:
91
+ """注册对象编码器;migration[n] 负责把版本 n 升级到 n+1。"""
92
+
93
+ normalized = type_id.strip()
94
+ if not normalized:
95
+ raise ValueError("type_id 不能为空")
96
+ if schema_version <= 0:
97
+ raise ValueError("schema_version 必须大于 0")
98
+ if normalized in self._by_type_id:
99
+ raise ValueError(f"序列化类型已注册:{normalized}")
100
+ if python_type in self._by_python_type:
101
+ raise ValueError(f"Python 类型已注册:{python_type.__qualname__}")
102
+ codec = _Codec(
103
+ type_id=normalized,
104
+ python_type=python_type,
105
+ schema_version=schema_version,
106
+ encoder=encoder,
107
+ decoder=decoder,
108
+ migrations=dict(migrations or {}),
109
+ )
110
+ self._by_type_id[normalized] = codec
111
+ self._by_python_type[python_type] = codec
112
+
113
+ def dump(self, value: Any) -> SerializedEnvelope:
114
+ codec = self._by_python_type.get(type(value))
115
+ if codec is None:
116
+ raise UnknownSerializedTypeError(f"类型未注册:{type(value).__qualname__}")
117
+ try:
118
+ payload = codec.encoder(value)
119
+ except SerializationError:
120
+ raise
121
+ except Exception as exc:
122
+ raise SerializationError(f"{codec.type_id} 编码失败:{exc}") from exc
123
+ if not isinstance(payload, dict):
124
+ raise SerializationError(f"{codec.type_id} 编码器必须返回 dict")
125
+ return SerializedEnvelope(codec.type_id, codec.schema_version, payload)
126
+
127
+ def load(self, value: SerializedEnvelope | Mapping[str, Any]) -> Any:
128
+ envelope = value if isinstance(value, SerializedEnvelope) else SerializedEnvelope.from_dict(value)
129
+ codec = self._by_type_id.get(envelope.type_id)
130
+ if codec is None:
131
+ raise UnknownSerializedTypeError(f"序列化类型未注册:{envelope.type_id}")
132
+ if envelope.schema_version > codec.schema_version:
133
+ raise UnsupportedSchemaVersionError(
134
+ f"{envelope.type_id} 版本 {envelope.schema_version} 高于当前版本 {codec.schema_version}"
135
+ )
136
+ payload = dict(envelope.payload)
137
+ version = envelope.schema_version
138
+ while version < codec.schema_version:
139
+ migration = codec.migrations.get(version)
140
+ if migration is None:
141
+ raise UnsupportedSchemaVersionError(
142
+ f"{envelope.type_id} 缺少 {version} -> {version + 1} 迁移函数"
143
+ )
144
+ try:
145
+ payload = migration(payload)
146
+ except Exception as exc:
147
+ raise SerializationError(f"{envelope.type_id} 从版本 {version} 迁移失败:{exc}") from exc
148
+ if not isinstance(payload, dict):
149
+ raise SerializationError(
150
+ f"{envelope.type_id} 的 {version} -> {version + 1} 迁移必须返回 dict"
151
+ )
152
+ version += 1
153
+ try:
154
+ return codec.decoder(payload)
155
+ except SerializationError:
156
+ raise
157
+ except Exception as exc:
158
+ raise SerializationError(f"{envelope.type_id} 解码失败:{exc}") from exc
159
+
160
+ def dumps(self, value: Any, *, ensure_ascii: bool = False) -> str:
161
+ try:
162
+ return json.dumps(self.dump(value).to_dict(), ensure_ascii=ensure_ascii)
163
+ except (TypeError, ValueError) as exc:
164
+ raise SerializationError(f"对象不能编码为 JSON:{exc}") from exc
165
+
166
+ def loads(self, value: str) -> Any:
167
+ try:
168
+ decoded = json.loads(value)
169
+ except json.JSONDecodeError as exc:
170
+ raise SerializationError(f"无效 JSON:{exc}") from exc
171
+ if not isinstance(decoded, dict):
172
+ raise SerializationError("序列化根节点必须是对象")
173
+ return self.load(decoded)
174
+
175
+
176
+ def _encode_blob(value: Blob) -> dict[str, Any]:
177
+ return {
178
+ "data": base64.b64encode(value.data).decode("ascii"),
179
+ "data_encoding": "base64",
180
+ "source": value.source,
181
+ "mime_type": value.mime_type,
182
+ "metadata": dict(value.metadata),
183
+ "checksum": value.checksum,
184
+ }
185
+
186
+
187
+ def _decode_blob(value: dict[str, Any]) -> Blob:
188
+ if value.get("data_encoding") != "base64":
189
+ raise SerializationError("Blob 只接受 base64 数据")
190
+ try:
191
+ data = base64.b64decode(str(value.get("data", "")), validate=True)
192
+ except (ValueError, binascii.Error) as exc:
193
+ raise SerializationError("Blob 包含无效 base64 数据") from exc
194
+ return Blob(
195
+ data=data,
196
+ source=value.get("source"),
197
+ mime_type=value.get("mime_type"),
198
+ metadata=dict(value.get("metadata") or {}),
199
+ checksum=str(value.get("checksum", "")),
200
+ )
201
+
202
+
203
+ def _decode_message(value: dict[str, Any]) -> Message:
204
+ return Message.from_dict(value)
205
+
206
+
207
+ def _decode_access(value: dict[str, Any]) -> AccessContext:
208
+ return AccessContext(
209
+ user_id=str(value.get("user_id", "anonymous")),
210
+ tenant_id=str(value.get("tenant_id", "default")),
211
+ roles=frozenset(str(role) for role in value.get("roles", ["user"])),
212
+ )
213
+
214
+
215
+ def _build_default_registry() -> SerializerRegistry:
216
+ registry = SerializerRegistry()
217
+ registry.register(
218
+ "agent_core.blob",
219
+ Blob,
220
+ schema_version=1,
221
+ encoder=_encode_blob,
222
+ decoder=_decode_blob,
223
+ )
224
+ registry.register(
225
+ "agent_core.document_locator",
226
+ DocumentLocator,
227
+ schema_version=1,
228
+ encoder=DocumentLocator.to_dict,
229
+ decoder=DocumentLocator.from_dict,
230
+ )
231
+ registry.register(
232
+ "agent_core.document",
233
+ Document,
234
+ schema_version=1,
235
+ encoder=Document.to_dict,
236
+ decoder=Document.from_dict,
237
+ )
238
+ registry.register(
239
+ "agent_core.document_chunk",
240
+ DocumentChunk,
241
+ schema_version=1,
242
+ encoder=DocumentChunk.to_dict,
243
+ decoder=DocumentChunk.from_dict,
244
+ )
245
+ registry.register(
246
+ "agent_core.message",
247
+ Message,
248
+ schema_version=1,
249
+ encoder=Message.to_dict,
250
+ decoder=_decode_message,
251
+ )
252
+ registry.register(
253
+ "agent_core.run_event",
254
+ RunEvent,
255
+ schema_version=1,
256
+ encoder=RunEvent.to_dict,
257
+ decoder=RunEvent.from_dict,
258
+ )
259
+ registry.register(
260
+ "agent_core.run_context",
261
+ RunContext,
262
+ schema_version=1,
263
+ encoder=RunContext.to_dict,
264
+ decoder=RunContext.from_dict,
265
+ )
266
+ registry.register(
267
+ "agent_core.approval_record",
268
+ ApprovalRecord,
269
+ schema_version=1,
270
+ encoder=ApprovalRecord.to_dict,
271
+ decoder=ApprovalRecord.from_dict,
272
+ )
273
+ registry.register(
274
+ "agent_core.tool_result",
275
+ ToolResult,
276
+ schema_version=1,
277
+ encoder=ToolResult.to_dict,
278
+ decoder=ToolResult.from_dict,
279
+ )
280
+ registry.register(
281
+ "agent_core.access_context",
282
+ AccessContext,
283
+ schema_version=1,
284
+ encoder=AccessContext.to_dict,
285
+ decoder=_decode_access,
286
+ )
287
+ return registry
288
+
289
+
290
+ default_serializer_registry = _build_default_registry()
291
+
292
+
293
+ def dumpd(value: Any, *, registry: SerializerRegistry = default_serializer_registry) -> dict[str, Any]:
294
+ """把对象编码为包含类型和版本的字典。"""
295
+
296
+ return registry.dump(value).to_dict()
297
+
298
+
299
+ def dumps(value: Any, *, registry: SerializerRegistry = default_serializer_registry) -> str:
300
+ """把对象安全编码为 JSON。"""
301
+
302
+ return registry.dumps(value)
303
+
304
+
305
+ def load(
306
+ value: SerializedEnvelope | Mapping[str, Any],
307
+ *,
308
+ registry: SerializerRegistry = default_serializer_registry,
309
+ ) -> Any:
310
+ """从版本化信封恢复白名单中的对象。"""
311
+
312
+ return registry.load(value)
313
+
314
+
315
+ def loads(value: str, *, registry: SerializerRegistry = default_serializer_registry) -> Any:
316
+ """从 JSON 恢复白名单中的对象。"""
317
+
318
+ return registry.loads(value)
@@ -0,0 +1,24 @@
1
+ """可复用 Skill 清单、加载器和注册表。"""
2
+
3
+ from agent_core.skills.errors import (
4
+ SkillBindingError,
5
+ SkillError,
6
+ SkillLoadError,
7
+ SkillRegistrationError,
8
+ )
9
+ from agent_core.skills.loader import SkillLoader
10
+ from agent_core.skills.registry import SkillRegistry
11
+ from agent_core.skills.types import SkillActivation, SkillSpec, SkillToolKind, SkillToolSpec
12
+
13
+ __all__ = [
14
+ "SkillActivation",
15
+ "SkillBindingError",
16
+ "SkillError",
17
+ "SkillLoadError",
18
+ "SkillLoader",
19
+ "SkillRegistrationError",
20
+ "SkillRegistry",
21
+ "SkillSpec",
22
+ "SkillToolKind",
23
+ "SkillToolSpec",
24
+ ]
@@ -0,0 +1,27 @@
1
+ """Skill 加载、注册和绑定错误。"""
2
+
3
+ from agent_core.errors import ConfigurationError
4
+
5
+
6
+ class SkillError(ConfigurationError):
7
+ """Skill 配置基础错误。"""
8
+
9
+
10
+ class SkillLoadError(SkillError):
11
+ """Skill 清单或指令文件无法加载。"""
12
+
13
+
14
+ class SkillRegistrationError(SkillError):
15
+ """Skill 名称或工具名称发生注册冲突。"""
16
+
17
+
18
+ class SkillBindingError(SkillError):
19
+ """Skill 声明的 Function 或 MCP 客户端无法绑定。"""
20
+
21
+
22
+ __all__ = [
23
+ "SkillBindingError",
24
+ "SkillError",
25
+ "SkillLoadError",
26
+ "SkillRegistrationError",
27
+ ]
@@ -0,0 +1,258 @@
1
+ """从受约束的 JSON 清单加载 Skill。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Iterable
8
+ from pathlib import Path
9
+ from typing import Any, cast
10
+
11
+ from agent_core.skills.errors import SkillLoadError
12
+ from agent_core.skills.types import SkillSpec, SkillToolKind, SkillToolSpec
13
+
14
+ _NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
15
+ _TOOL_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
16
+ _SKILL_FIELDS = {
17
+ "name",
18
+ "description",
19
+ "version",
20
+ "instructions",
21
+ "instructions_file",
22
+ "tools",
23
+ "metadata",
24
+ }
25
+ _TOOL_FIELDS = {
26
+ "name",
27
+ "kind",
28
+ "target",
29
+ "server",
30
+ "description",
31
+ "parameters",
32
+ "risk_level",
33
+ "requires_approval",
34
+ "timeout_seconds",
35
+ "idempotent",
36
+ }
37
+
38
+
39
+ class SkillLoader:
40
+ """加载 ``skill.json``,并校验其关联指令文件位于 Skill 目录内。"""
41
+
42
+ def __init__(self, *, manifest_name: str = "skill.json") -> None:
43
+ if not manifest_name or Path(manifest_name).name != manifest_name:
44
+ raise ValueError("manifest_name 必须是单个文件名")
45
+ self._manifest_name = manifest_name
46
+
47
+ def load(self, path: str | Path) -> SkillSpec:
48
+ """从 Skill 目录或 JSON 清单文件加载一个 Skill。"""
49
+ source = Path(path).expanduser()
50
+ manifest = source / self._manifest_name if source.is_dir() else source
51
+ manifest = manifest.resolve()
52
+ if not manifest.is_file():
53
+ raise SkillLoadError(f"Skill 清单不存在:{manifest}")
54
+
55
+ data = self._read_manifest(manifest)
56
+ self._reject_unknown_fields(data, _SKILL_FIELDS, f"Skill {manifest}")
57
+
58
+ name = self._required_string(data, "name", f"Skill {manifest}")
59
+ if not _NAME_PATTERN.fullmatch(name):
60
+ raise SkillLoadError(f"Skill 名称格式无效:{name!r};只允许字母、数字、点、下划线和连字符")
61
+ description = self._required_string(data, "description", f"Skill {name}")
62
+ version = self._required_string(data, "version", f"Skill {name}")
63
+ instructions = self._load_instructions(data, manifest, name)
64
+
65
+ raw_tools = data.get("tools", [])
66
+ if not isinstance(raw_tools, list):
67
+ raise SkillLoadError(f"Skill {name} 的 tools 必须是数组")
68
+ tools = tuple(
69
+ self._parse_tool(item, skill_name=name, index=index) for index, item in enumerate(raw_tools)
70
+ )
71
+ duplicate_tools = self._duplicates(tool.name for tool in tools)
72
+ if duplicate_tools:
73
+ raise SkillLoadError(f"Skill {name} 包含重复工具名称:{', '.join(duplicate_tools)}")
74
+
75
+ metadata = data.get("metadata", {})
76
+ if not isinstance(metadata, dict):
77
+ raise SkillLoadError(f"Skill {name} 的 metadata 必须是对象")
78
+
79
+ return SkillSpec(
80
+ name=name,
81
+ description=description,
82
+ version=version,
83
+ instructions=instructions,
84
+ tools=tools,
85
+ metadata=dict(metadata),
86
+ source=manifest,
87
+ )
88
+
89
+ def load_directory(self, directory: str | Path) -> tuple[SkillSpec, ...]:
90
+ """递归加载目录下的所有 ``skill.json``,结果按路径稳定排序。"""
91
+ root = Path(directory).expanduser().resolve()
92
+ if not root.is_dir():
93
+ raise SkillLoadError(f"Skill 目录不存在:{root}")
94
+ manifests = sorted(root.rglob(self._manifest_name))
95
+ if not manifests:
96
+ raise SkillLoadError(f"目录中没有找到 {self._manifest_name}:{root}")
97
+ return tuple(self.load(manifest) for manifest in manifests)
98
+
99
+ @staticmethod
100
+ def _read_manifest(manifest: Path) -> dict[str, Any]:
101
+ try:
102
+ raw = json.loads(manifest.read_text(encoding="utf-8"))
103
+ except OSError as exc:
104
+ raise SkillLoadError(f"无法读取 Skill 清单:{manifest}({exc})") from exc
105
+ except json.JSONDecodeError as exc:
106
+ raise SkillLoadError(f"Skill 清单不是有效 JSON:{manifest}:{exc.lineno}:{exc.colno}") from exc
107
+ if not isinstance(raw, dict):
108
+ raise SkillLoadError(f"Skill 清单根节点必须是对象:{manifest}")
109
+ return cast(dict[str, Any], raw)
110
+
111
+ def _load_instructions(
112
+ self,
113
+ data: dict[str, Any],
114
+ manifest: Path,
115
+ skill_name: str,
116
+ ) -> str:
117
+ inline = data.get("instructions")
118
+ filename = data.get("instructions_file")
119
+ if inline is not None and filename is not None:
120
+ raise SkillLoadError(f"Skill {skill_name} 不能同时设置 instructions 和 instructions_file")
121
+ if inline is not None:
122
+ if not isinstance(inline, str):
123
+ raise SkillLoadError(f"Skill {skill_name} 的 instructions 必须是字符串")
124
+ return inline.strip()
125
+ if filename is None:
126
+ return ""
127
+ if not isinstance(filename, str) or not filename.strip():
128
+ raise SkillLoadError(f"Skill {skill_name} 的 instructions_file 必须是非空字符串")
129
+
130
+ skill_root = manifest.parent.resolve()
131
+ instruction_path = (skill_root / filename).resolve()
132
+ try:
133
+ instruction_path.relative_to(skill_root)
134
+ except ValueError as exc:
135
+ raise SkillLoadError(f"Skill {skill_name} 的 instructions_file 不能越过 Skill 目录") from exc
136
+ if not instruction_path.is_file():
137
+ raise SkillLoadError(f"Skill {skill_name} 的指令文件不存在:{instruction_path}")
138
+ try:
139
+ return instruction_path.read_text(encoding="utf-8").strip()
140
+ except OSError as exc:
141
+ raise SkillLoadError(
142
+ f"无法读取 Skill {skill_name} 的指令文件:{instruction_path}({exc})"
143
+ ) from exc
144
+
145
+ def _parse_tool(
146
+ self,
147
+ raw: object,
148
+ *,
149
+ skill_name: str,
150
+ index: int,
151
+ ) -> SkillToolSpec:
152
+ context = f"Skill {skill_name} 的 tools[{index}]"
153
+ if not isinstance(raw, dict):
154
+ raise SkillLoadError(f"{context} 必须是对象")
155
+ data = cast(dict[str, Any], raw)
156
+ self._reject_unknown_fields(data, _TOOL_FIELDS, context)
157
+
158
+ name = self._required_string(data, "name", context)
159
+ if not _TOOL_NAME_PATTERN.fullmatch(name):
160
+ raise SkillLoadError(
161
+ f"{context} 的工具名称格式无效:{name!r};必须符合 Function Calling 命名规则"
162
+ )
163
+ kind = self._required_string(data, "kind", context).lower()
164
+ if kind not in {"function", "mcp"}:
165
+ raise SkillLoadError(f"{context} 的 kind 只支持 function 或 mcp")
166
+ description = self._required_string(data, "description", context)
167
+ target = data.get("target", name)
168
+ if not isinstance(target, str) or not target.strip():
169
+ raise SkillLoadError(f"{context} 的 target 必须是非空字符串")
170
+ target = target.strip()
171
+
172
+ server = data.get("server")
173
+ if kind == "mcp":
174
+ if not isinstance(server, str) or not server.strip():
175
+ raise SkillLoadError(f"{context} 的 MCP 工具必须声明 server")
176
+ server = server.strip()
177
+ elif server is not None:
178
+ raise SkillLoadError(f"{context} 的 Function 工具不能声明 server")
179
+
180
+ parameters = data.get(
181
+ "parameters",
182
+ {"type": "object", "properties": {}, "required": []},
183
+ )
184
+ self._validate_parameters(parameters, context)
185
+
186
+ risk_level = data.get("risk_level", "read")
187
+ if not isinstance(risk_level, str) or not risk_level.strip():
188
+ raise SkillLoadError(f"{context} 的 risk_level 必须是非空字符串")
189
+ requires_approval = self._boolean(data, "requires_approval", False, context)
190
+ idempotent = self._boolean(data, "idempotent", True, context)
191
+ timeout_seconds = data.get("timeout_seconds")
192
+ if timeout_seconds is not None:
193
+ if (
194
+ isinstance(timeout_seconds, bool)
195
+ or not isinstance(timeout_seconds, (int, float))
196
+ or timeout_seconds <= 0
197
+ ):
198
+ raise SkillLoadError(f"{context} 的 timeout_seconds 必须大于 0")
199
+ timeout_seconds = float(timeout_seconds)
200
+
201
+ return SkillToolSpec(
202
+ name=name,
203
+ kind=cast(SkillToolKind, kind),
204
+ target=target,
205
+ server=server,
206
+ description=description,
207
+ parameters=dict(cast(dict[str, Any], parameters)),
208
+ risk_level=risk_level.strip(),
209
+ requires_approval=requires_approval,
210
+ timeout_seconds=timeout_seconds,
211
+ idempotent=idempotent,
212
+ )
213
+
214
+ @staticmethod
215
+ def _validate_parameters(parameters: object, context: str) -> None:
216
+ if not isinstance(parameters, dict):
217
+ raise SkillLoadError(f"{context} 的 parameters 必须是 JSON Schema 对象")
218
+ if parameters.get("type", "object") != "object":
219
+ raise SkillLoadError(f"{context} 的 parameters.type 必须是 object")
220
+ properties = parameters.get("properties", {})
221
+ required = parameters.get("required", [])
222
+ if not isinstance(properties, dict):
223
+ raise SkillLoadError(f"{context} 的 parameters.properties 必须是对象")
224
+ if not isinstance(required, list) or not all(isinstance(item, str) for item in required):
225
+ raise SkillLoadError(f"{context} 的 parameters.required 必须是字符串数组")
226
+
227
+ @staticmethod
228
+ def _required_string(data: dict[str, Any], field: str, context: str) -> str:
229
+ value = data.get(field)
230
+ if not isinstance(value, str) or not value.strip():
231
+ raise SkillLoadError(f"{context} 缺少非空字符串字段 {field}")
232
+ return value.strip()
233
+
234
+ @staticmethod
235
+ def _boolean(data: dict[str, Any], field: str, default: bool, context: str) -> bool:
236
+ value = data.get(field, default)
237
+ if not isinstance(value, bool):
238
+ raise SkillLoadError(f"{context} 的 {field} 必须是布尔值")
239
+ return value
240
+
241
+ @staticmethod
242
+ def _reject_unknown_fields(data: dict[str, Any], allowed: set[str], context: str) -> None:
243
+ unknown = sorted(set(data) - allowed)
244
+ if unknown:
245
+ raise SkillLoadError(f"{context} 包含未知字段:{', '.join(unknown)}")
246
+
247
+ @staticmethod
248
+ def _duplicates(values: Iterable[str]) -> list[str]:
249
+ seen: set[str] = set()
250
+ duplicates: set[str] = set()
251
+ for value in values:
252
+ if value in seen:
253
+ duplicates.add(value)
254
+ seen.add(value)
255
+ return sorted(duplicates)
256
+
257
+
258
+ __all__ = ["SkillLoader"]