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.
- agent_core/__init__.py +525 -0
- agent_core/access/__init__.py +6 -0
- agent_core/access/context.py +45 -0
- agent_core/access/guard.py +40 -0
- agent_core/acp/README.md +236 -0
- agent_core/acp/__init__.py +20 -0
- agent_core/acp/server.py +351 -0
- agent_core/acp/types.py +86 -0
- agent_core/callbacks/__init__.py +17 -0
- agent_core/callbacks/manager.py +105 -0
- agent_core/checkpoint/__init__.py +19 -0
- agent_core/checkpoint/naming.py +93 -0
- agent_core/checkpoint/store.py +186 -0
- agent_core/checkpoint/travel.py +448 -0
- agent_core/compaction/__init__.py +57 -0
- agent_core/compaction/middleware.py +428 -0
- agent_core/compaction/prompts.py +32 -0
- agent_core/compaction/pruner.py +121 -0
- agent_core/compaction/region.py +145 -0
- agent_core/compaction/spill.py +81 -0
- agent_core/compaction/spill_tool.py +42 -0
- agent_core/compaction/summarizer.py +95 -0
- agent_core/compaction/token_limit.py +74 -0
- agent_core/compaction/types.py +93 -0
- agent_core/docs.py +15 -0
- agent_core/documents/__init__.py +23 -0
- agent_core/documents/loaders.py +79 -0
- agent_core/documents/splitters.py +94 -0
- agent_core/documents/types.py +197 -0
- agent_core/errors.py +81 -0
- agent_core/guardrails/__init__.py +19 -0
- agent_core/guardrails/core.py +186 -0
- agent_core/hitl/__init__.py +15 -0
- agent_core/hitl/core.py +517 -0
- agent_core/mcp/__init__.py +14 -0
- agent_core/mcp/client.py +212 -0
- agent_core/mcp/errors.py +26 -0
- agent_core/middleware/__init__.py +26 -0
- agent_core/middleware/base.py +283 -0
- agent_core/model/__init__.py +73 -0
- agent_core/model/catalog.py +73 -0
- agent_core/model/core.py +179 -0
- agent_core/model/governance.py +317 -0
- agent_core/model/providers/__init__.py +43 -0
- agent_core/model/providers/anthropic.py +294 -0
- agent_core/model/providers/common.py +28 -0
- agent_core/model/providers/factory.py +107 -0
- agent_core/model/providers/gemini.py +273 -0
- agent_core/model/providers/ollama.py +80 -0
- agent_core/model/providers/openai_compatible.py +526 -0
- agent_core/model/structured.py +200 -0
- agent_core/observability/__init__.py +21 -0
- agent_core/observability/core.py +311 -0
- agent_core/ports/__init__.py +31 -0
- agent_core/ports/memory.py +130 -0
- agent_core/ports/model_selection.py +64 -0
- agent_core/ports/storage.py +114 -0
- agent_core/prompts/__init__.py +19 -0
- agent_core/prompts/registry.py +236 -0
- agent_core/prompts/templates.py +166 -0
- agent_core/protocol/__init__.py +31 -0
- agent_core/protocol/capabilities.py +49 -0
- agent_core/protocol/messages.py +100 -0
- agent_core/protocol/runtime.py +293 -0
- agent_core/py.typed +1 -0
- agent_core/retrieval/__init__.py +40 -0
- agent_core/retrieval/_common.py +121 -0
- agent_core/retrieval/chroma.py +269 -0
- agent_core/retrieval/core.py +63 -0
- agent_core/retrieval/factory.py +72 -0
- agent_core/retrieval/memory.py +232 -0
- agent_core/retrieval/pgvector.py +239 -0
- agent_core/retrieval/types.py +95 -0
- agent_core/runtime/__init__.py +28 -0
- agent_core/runtime/compat.py +98 -0
- agent_core/runtime/react.py +888 -0
- agent_core/runtime/service.py +557 -0
- agent_core/runtime/store.py +55 -0
- agent_core/serialization/__init__.py +21 -0
- agent_core/serialization/registry.py +318 -0
- agent_core/skills/__init__.py +24 -0
- agent_core/skills/errors.py +27 -0
- agent_core/skills/loader.py +258 -0
- agent_core/skills/registry.py +205 -0
- agent_core/skills/types.py +56 -0
- agent_core/storage/__init__.py +107 -0
- agent_core/storage/_sqlite_utils.py +92 -0
- agent_core/storage/context.py +382 -0
- agent_core/storage/lease.py +435 -0
- agent_core/storage/memory.py +604 -0
- agent_core/storage/model_selection.py +360 -0
- agent_core/storage/runtime.py +899 -0
- agent_core/storage/session.py +904 -0
- agent_core/storage/workflow.py +411 -0
- agent_core/tools/__init__.py +5 -0
- agent_core/tools/executor.py +306 -0
- agent_core/workflow/__init__.py +25 -0
- agent_core/workflow/durable.py +175 -0
- agent_core/workflow/state_machine.py +427 -0
- zero_agent_core-0.2.0.dist-info/METADATA +237 -0
- zero_agent_core-0.2.0.dist-info/RECORD +104 -0
- zero_agent_core-0.2.0.dist-info/WHEEL +4 -0
- zero_agent_core-0.2.0.dist-info/entry_points.txt +3 -0
- 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"]
|