python-library-ai-agent 0.1.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.
- ai_agent/__init__.py +66 -0
- ai_agent/agent.py +122 -0
- ai_agent/app/__init__.py +10 -0
- ai_agent/app/_workspace.py +127 -0
- ai_agent/app/app.py +321 -0
- ai_agent/app/harness_io.py +109 -0
- ai_agent/app/output_format.py +77 -0
- ai_agent/app/packet.py +39 -0
- ai_agent/app/session.py +742 -0
- ai_agent/app/session_store.py +85 -0
- ai_agent/builtin_tools/__init__.py +18 -0
- ai_agent/builtin_tools/current_time.py +39 -0
- ai_agent/builtin_tools/pack.py +20 -0
- ai_agent/builtin_tools/prefix.py +11 -0
- ai_agent/context.py +151 -0
- ai_agent/harness/__init__.py +3 -0
- ai_agent/harness/current_time.py +25 -0
- ai_agent/harness/harness.py +324 -0
- ai_agent/harness/process.py +115 -0
- ai_agent/harness/prompts.py +38 -0
- ai_agent/harness/sandbox.py +139 -0
- ai_agent/json_extract.py +70 -0
- ai_agent/listener.py +172 -0
- ai_agent/llm.py +39 -0
- ai_agent/llm_openai.py +117 -0
- ai_agent/loop.py +124 -0
- ai_agent/mcp_config.py +54 -0
- ai_agent/mcp_loader.py +110 -0
- ai_agent/memory/__init__.py +9 -0
- ai_agent/memory/compression_work.py +71 -0
- ai_agent/memory/compressor.py +339 -0
- ai_agent/memory/config.py +40 -0
- ai_agent/memory/context_builder.py +57 -0
- ai_agent/memory/memory_system.py +561 -0
- ai_agent/memory/models.py +76 -0
- ai_agent/memory/snapshot_merge.py +158 -0
- ai_agent/memory/store.py +107 -0
- ai_agent/memory/worker.py +227 -0
- ai_agent/plan/__init__.py +15 -0
- ai_agent/plan/complete.py +64 -0
- ai_agent/plan/delivery.py +41 -0
- ai_agent/plan/display.py +46 -0
- ai_agent/plan/models.py +44 -0
- ai_agent/plan/parse.py +39 -0
- ai_agent/plan/planner.py +204 -0
- ai_agent/plan/runner.py +281 -0
- ai_agent/react_tool_turn.py +39 -0
- ai_agent/rule/__init__.py +3 -0
- ai_agent/rule/rules.py +36 -0
- ai_agent/skill/__init__.py +5 -0
- ai_agent/skill/builtin_registry.py +56 -0
- ai_agent/skill/catalog.py +104 -0
- ai_agent/skill/frontmatter.py +83 -0
- ai_agent/skill/manager.py +486 -0
- ai_agent/skill/models.py +31 -0
- ai_agent/skill/roots.py +150 -0
- ai_agent/skill/skill_kit.py +80 -0
- ai_agent/skill/tool_declarations.py +68 -0
- ai_agent/tools.py +123 -0
- python_library_ai_agent-0.1.0.dist-info/METADATA +10 -0
- python_library_ai_agent-0.1.0.dist-info/RECORD +62 -0
- python_library_ai_agent-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ai_agent.skill import catalog
|
|
8
|
+
from ai_agent.skill.builtin_registry import BuiltinToolRegistry
|
|
9
|
+
from ai_agent.skill.catalog import SkillSummary
|
|
10
|
+
from ai_agent.skill.frontmatter import split_frontmatter
|
|
11
|
+
from ai_agent.skill.models import LoadedSkill, SkillToolDecl
|
|
12
|
+
from ai_agent.skill.roots import SkillRootsSandbox, normalize_skill_roots
|
|
13
|
+
from ai_agent.context import RunContext
|
|
14
|
+
from ai_agent.skill.tool_declarations import parse_tool_declarations
|
|
15
|
+
from ai_agent.tools import Tool, ToolRegistry
|
|
16
|
+
|
|
17
|
+
_SKILL_TOOL_PREFIX = "skill"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SkillManager:
|
|
21
|
+
"""
|
|
22
|
+
技能动态能力层:扫描、加载、启用与停用,并向工具表注入管理与子工具。
|
|
23
|
+
|
|
24
|
+
启用技能时加载全文并暴露子工具;单次对话运行内正文可拼入临时系统上下文,
|
|
25
|
+
运行结束后恢复。技能仓库运行时只读,不可通过工具改写磁盘文件。
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
roots: 技能根目录;可为单路径、路径序列或根键到路径的映射
|
|
29
|
+
builtin_registry: 宿主预注册的内置工具表;未传则新建空表(供测试注入)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
roots: Mapping[str, Path | str] | Sequence[Path | str] | Path | str,
|
|
35
|
+
*,
|
|
36
|
+
builtin_registry: BuiltinToolRegistry | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
mapping = normalize_skill_roots(roots)
|
|
39
|
+
self._sandbox = SkillRootsSandbox(mapping)
|
|
40
|
+
self._prefix = _SKILL_TOOL_PREFIX
|
|
41
|
+
self._builtin = builtin_registry or BuiltinToolRegistry()
|
|
42
|
+
self._registry: ToolRegistry | None = None
|
|
43
|
+
self._available: dict[str, SkillSummary] = {}
|
|
44
|
+
self._loaded: dict[str, LoadedSkill] = {}
|
|
45
|
+
self._enabled: set[str] = set()
|
|
46
|
+
self._skill_tools: dict[str, list[Tool]] = {}
|
|
47
|
+
self._run: RunContext | None = None
|
|
48
|
+
self._run_enabled_refs: set[str] = set()
|
|
49
|
+
self._run_context_refs: set[str] = set()
|
|
50
|
+
self._plan_active = False
|
|
51
|
+
self._plan_delivery_refs: tuple[str, ...] = ()
|
|
52
|
+
self.refresh()
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def root_keys(self) -> tuple[str, ...]:
|
|
56
|
+
"""已配置的根键名。"""
|
|
57
|
+
return self._sandbox.root_keys
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def enabled_skill_refs(self) -> tuple[str, ...]:
|
|
61
|
+
"""当前已启用的 skill 引用。"""
|
|
62
|
+
return tuple(sorted(self._enabled))
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def run_context_skill_refs(self) -> tuple[str, ...]:
|
|
66
|
+
"""当前 ReAct 运行内已注入上下文的 skill 引用。"""
|
|
67
|
+
return tuple(sorted(self._run_context_refs))
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def builtin_registry(self) -> BuiltinToolRegistry:
|
|
71
|
+
"""内置工具注册表,供宿主扩展。"""
|
|
72
|
+
return self._builtin
|
|
73
|
+
|
|
74
|
+
def bind_registry(self, registry: ToolRegistry) -> None:
|
|
75
|
+
"""绑定会话级工具表;变更启用状态后须 ``sync_to_registry``。"""
|
|
76
|
+
self._registry = registry
|
|
77
|
+
|
|
78
|
+
def sync_to_registry(self) -> None:
|
|
79
|
+
"""按当前状态刷新工具表中的管理与 skill 子工具层。"""
|
|
80
|
+
if self._registry is None:
|
|
81
|
+
return
|
|
82
|
+
self._registry.set_management_tools(self.build_management_tools())
|
|
83
|
+
self._registry.set_skill_tools(self.build_enabled_tools())
|
|
84
|
+
|
|
85
|
+
def begin_plan(self) -> None:
|
|
86
|
+
"""标记一次 ``PlanRunner.run`` 开始;与 ``end_plan`` 成对调用。"""
|
|
87
|
+
self._plan_active = True
|
|
88
|
+
self._plan_delivery_refs = ()
|
|
89
|
+
|
|
90
|
+
def end_plan(self) -> None:
|
|
91
|
+
"""结束计划作用域并停用计划期间仍挂起的 skill。"""
|
|
92
|
+
self._plan_active = False
|
|
93
|
+
self._plan_delivery_refs = ()
|
|
94
|
+
for ref in list(self._enabled):
|
|
95
|
+
self._disable_unlocked(ref)
|
|
96
|
+
self.sync_to_registry()
|
|
97
|
+
|
|
98
|
+
def set_plan_delivery_skills(self, refs: tuple[str, ...]) -> None:
|
|
99
|
+
"""
|
|
100
|
+
指定下一步 ReAct 开始前预载入上下文的终稿 skill。
|
|
101
|
+
|
|
102
|
+
仅在 ``begin_plan`` 之后、下一步 ``begin_run`` 之前调用;非终稿步传空元组。
|
|
103
|
+
"""
|
|
104
|
+
if not self._plan_active:
|
|
105
|
+
return
|
|
106
|
+
self._plan_delivery_refs = refs
|
|
107
|
+
|
|
108
|
+
def begin_run(self, run: RunContext) -> None:
|
|
109
|
+
"""
|
|
110
|
+
标记新一轮 ReAct 开始;运行内启用的 skill 在 ``end_run`` 时还原。
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
run: 当前 ``RunContext``
|
|
114
|
+
"""
|
|
115
|
+
self._run = run
|
|
116
|
+
self._run_enabled_refs = set()
|
|
117
|
+
self._run_context_refs = set()
|
|
118
|
+
run.ephemeral_skill_context = ""
|
|
119
|
+
if self._plan_active and self._plan_delivery_refs:
|
|
120
|
+
for ref in self._plan_delivery_refs:
|
|
121
|
+
self._activate_skill_for_run(ref)
|
|
122
|
+
|
|
123
|
+
def end_run(self) -> None:
|
|
124
|
+
"""结束当前 ReAct 运行:清空临时 skill 上下文并停用本运行内启用的 skill。"""
|
|
125
|
+
if self._run is not None:
|
|
126
|
+
self._run.ephemeral_skill_context = ""
|
|
127
|
+
for ref in list(self._run_enabled_refs):
|
|
128
|
+
self._disable_unlocked(ref)
|
|
129
|
+
self._run = None
|
|
130
|
+
self._run_enabled_refs = set()
|
|
131
|
+
self._run_context_refs = set()
|
|
132
|
+
self._plan_delivery_refs = ()
|
|
133
|
+
self.sync_to_registry()
|
|
134
|
+
|
|
135
|
+
def refresh(self) -> str:
|
|
136
|
+
"""
|
|
137
|
+
重新扫描 skill 根目录,并移除已不存在 skill 的启用状态。
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
扫描结果摘要
|
|
141
|
+
"""
|
|
142
|
+
summaries = catalog.scan_skills(self._sandbox)
|
|
143
|
+
self._available = {item.skill_ref: item for item in summaries}
|
|
144
|
+
stale = [ref for ref in self._enabled if ref not in self._available]
|
|
145
|
+
for ref in stale:
|
|
146
|
+
self._disable_unlocked(ref)
|
|
147
|
+
self._loaded = {
|
|
148
|
+
ref: loaded
|
|
149
|
+
for ref, loaded in self._loaded.items()
|
|
150
|
+
if ref in self._available
|
|
151
|
+
}
|
|
152
|
+
return catalog.format_skill_list(summaries)
|
|
153
|
+
|
|
154
|
+
def list_skills(self, root_key: str = "") -> str:
|
|
155
|
+
"""
|
|
156
|
+
列出可用 skill 摘要。
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
root_key: 仅扫描该根;留空扫描全部
|
|
160
|
+
"""
|
|
161
|
+
if root_key.strip():
|
|
162
|
+
key = self._sandbox.require_root(root_key)
|
|
163
|
+
items = [
|
|
164
|
+
item for item in self._available.values() if item.root_key == key
|
|
165
|
+
]
|
|
166
|
+
else:
|
|
167
|
+
items = list(self._available.values())
|
|
168
|
+
items.sort(key=lambda item: item.skill_ref)
|
|
169
|
+
lines = [catalog.format_skill_list(items)]
|
|
170
|
+
if self._enabled:
|
|
171
|
+
lines.append("")
|
|
172
|
+
lines.append("已启用: " + ", ".join(sorted(self._enabled)))
|
|
173
|
+
return "\n".join(lines)
|
|
174
|
+
|
|
175
|
+
def get_metadata(self, skill_ref: str) -> str:
|
|
176
|
+
"""读取 frontmatter 元数据摘要。"""
|
|
177
|
+
loaded = self._load(skill_ref)
|
|
178
|
+
meta = loaded.meta
|
|
179
|
+
if not meta:
|
|
180
|
+
return f"{skill_ref}:无 frontmatter"
|
|
181
|
+
out = [f"{skill_ref} 元数据:"]
|
|
182
|
+
for key in sorted(meta.keys()):
|
|
183
|
+
out.append(f" {key}: {meta[key]}")
|
|
184
|
+
if loaded.tool_decls:
|
|
185
|
+
out.append(" tools:")
|
|
186
|
+
for decl in loaded.tool_decls:
|
|
187
|
+
out.append(f" - {decl.name} ({decl.handler})")
|
|
188
|
+
return "\n".join(out)
|
|
189
|
+
|
|
190
|
+
def enable_skill(self, skill_ref: str) -> str:
|
|
191
|
+
"""
|
|
192
|
+
启用 skill:加载全文、注册子工具,并在当前 ReAct 运行内注入临时上下文。
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
skill_ref: ``{root_key}/{skill_id}``
|
|
196
|
+
"""
|
|
197
|
+
ref = self._normalize_ref(skill_ref)
|
|
198
|
+
if ref in self._run_context_refs:
|
|
199
|
+
return f"skill 已在当前运行上下文中: {ref}"
|
|
200
|
+
self._require_available(ref)
|
|
201
|
+
loaded = self._load(ref)
|
|
202
|
+
tools = self._build_skill_tools(ref, loaded.tool_decls)
|
|
203
|
+
self._skill_tools[ref] = tools
|
|
204
|
+
self._enabled.add(ref)
|
|
205
|
+
if self._run is not None:
|
|
206
|
+
self._run_enabled_refs.add(ref)
|
|
207
|
+
self._run_context_refs.add(ref)
|
|
208
|
+
self._append_run_skill_context(ref, loaded.text)
|
|
209
|
+
names = ", ".join(tool.name for tool in tools) if tools else "(无子工具)"
|
|
210
|
+
context_note = ";正文已注入本轮上下文" if self._run is not None else ""
|
|
211
|
+
return f"已启用 {ref};子工具: {names}{context_note}"
|
|
212
|
+
|
|
213
|
+
def disable_skill(self, skill_ref: str) -> str:
|
|
214
|
+
"""
|
|
215
|
+
停用 skill 并移除其子工具。
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
skill_ref: ``{root_key}/{skill_id}``
|
|
219
|
+
"""
|
|
220
|
+
ref = self._normalize_ref(skill_ref)
|
|
221
|
+
if ref not in self._enabled:
|
|
222
|
+
return f"skill 未启用: {ref}"
|
|
223
|
+
self._disable_unlocked(ref)
|
|
224
|
+
self._run_context_refs.discard(ref)
|
|
225
|
+
if self._run is not None:
|
|
226
|
+
self._rebuild_run_skill_context()
|
|
227
|
+
return f"已停用 {ref}"
|
|
228
|
+
|
|
229
|
+
def roots_info(self) -> str:
|
|
230
|
+
"""说明 skill 根目录约束。"""
|
|
231
|
+
keys = ", ".join(self._sandbox.root_keys)
|
|
232
|
+
lines = [
|
|
233
|
+
f"已配置 skill 根: {keys}。",
|
|
234
|
+
"引用格式为 {root_key}/{skill_id};",
|
|
235
|
+
"仅可访问各根下技能子目录中的文件,无法越出根目录。",
|
|
236
|
+
"使用 list_skills 扫描;enable_skill 启用后暴露子工具并注入本轮上下文。",
|
|
237
|
+
"skill 仓库在运行时只读,须在开发阶段维护文件。",
|
|
238
|
+
]
|
|
239
|
+
return "".join(lines)
|
|
240
|
+
|
|
241
|
+
def build_management_tools(self) -> list[Tool]:
|
|
242
|
+
"""生成 skill 管理工具(不含已启用 skill 的子工具)。"""
|
|
243
|
+
specs = self._management_specs()
|
|
244
|
+
return [
|
|
245
|
+
Tool(
|
|
246
|
+
name=self._tool_name(short),
|
|
247
|
+
description=description,
|
|
248
|
+
parameters=parameters,
|
|
249
|
+
handler=self._wrap_sync(handler) if sync_after else handler,
|
|
250
|
+
)
|
|
251
|
+
for short, description, parameters, handler, _writable_only, sync_after in specs
|
|
252
|
+
]
|
|
253
|
+
|
|
254
|
+
def build_enabled_tools(self) -> list[Tool]:
|
|
255
|
+
"""合并当前已启用 skill 暴露的全部子工具。"""
|
|
256
|
+
tools: list[Tool] = []
|
|
257
|
+
for ref in sorted(self._enabled):
|
|
258
|
+
tools.extend(self._skill_tools.get(ref, ()))
|
|
259
|
+
return tools
|
|
260
|
+
|
|
261
|
+
def build_all_flat_tools(self) -> list[Tool]:
|
|
262
|
+
"""管理工具与已启用子工具合并(兼容旧版 ``SkillKit.build_tools``)。"""
|
|
263
|
+
return self.build_management_tools() + self.build_enabled_tools()
|
|
264
|
+
|
|
265
|
+
def _management_specs(
|
|
266
|
+
self,
|
|
267
|
+
) -> list[
|
|
268
|
+
tuple[str, str, dict[str, Any], Callable[..., Any], bool, bool]
|
|
269
|
+
]:
|
|
270
|
+
return [
|
|
271
|
+
(
|
|
272
|
+
"list_skills",
|
|
273
|
+
"扫描已配置的 skill 根目录,列出技能及启用状态。",
|
|
274
|
+
{
|
|
275
|
+
"type": "object",
|
|
276
|
+
"properties": {
|
|
277
|
+
"root_key": {
|
|
278
|
+
"type": "string",
|
|
279
|
+
"description": "仅扫描该根键;留空扫描全部根",
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
"additionalProperties": False,
|
|
283
|
+
},
|
|
284
|
+
self.list_skills,
|
|
285
|
+
False,
|
|
286
|
+
False,
|
|
287
|
+
),
|
|
288
|
+
(
|
|
289
|
+
"enable_skill",
|
|
290
|
+
"启用指定 skill,使其声明的子工具加入当前会话工具表。",
|
|
291
|
+
{
|
|
292
|
+
"type": "object",
|
|
293
|
+
"properties": {
|
|
294
|
+
"skill_ref": {
|
|
295
|
+
"type": "string",
|
|
296
|
+
"description": "格式 {root_key}/{skill_id}",
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
"required": ["skill_ref"],
|
|
300
|
+
"additionalProperties": False,
|
|
301
|
+
},
|
|
302
|
+
self.enable_skill,
|
|
303
|
+
False,
|
|
304
|
+
True,
|
|
305
|
+
),
|
|
306
|
+
(
|
|
307
|
+
"disable_skill",
|
|
308
|
+
"停用指定 skill 并移除其子工具与本轮临时上下文。",
|
|
309
|
+
{
|
|
310
|
+
"type": "object",
|
|
311
|
+
"properties": {
|
|
312
|
+
"skill_ref": {
|
|
313
|
+
"type": "string",
|
|
314
|
+
"description": "格式 {root_key}/{skill_id}",
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
"required": ["skill_ref"],
|
|
318
|
+
"additionalProperties": False,
|
|
319
|
+
},
|
|
320
|
+
self.disable_skill,
|
|
321
|
+
False,
|
|
322
|
+
True,
|
|
323
|
+
),
|
|
324
|
+
(
|
|
325
|
+
"refresh_skills",
|
|
326
|
+
"重新扫描 skill 根目录并同步可用列表。",
|
|
327
|
+
{
|
|
328
|
+
"type": "object",
|
|
329
|
+
"properties": {},
|
|
330
|
+
"additionalProperties": False,
|
|
331
|
+
},
|
|
332
|
+
self.refresh,
|
|
333
|
+
False,
|
|
334
|
+
True,
|
|
335
|
+
),
|
|
336
|
+
(
|
|
337
|
+
"get_metadata",
|
|
338
|
+
"读取指定 skill 的 SKILL.md frontmatter 元数据。",
|
|
339
|
+
{
|
|
340
|
+
"type": "object",
|
|
341
|
+
"properties": {
|
|
342
|
+
"skill_ref": {
|
|
343
|
+
"type": "string",
|
|
344
|
+
"description": "格式 {root_key}/{skill_id}",
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
"required": ["skill_ref"],
|
|
348
|
+
"additionalProperties": False,
|
|
349
|
+
},
|
|
350
|
+
self.get_metadata,
|
|
351
|
+
False,
|
|
352
|
+
False,
|
|
353
|
+
),
|
|
354
|
+
(
|
|
355
|
+
"roots_info",
|
|
356
|
+
"查看 skill 根目录的使用约束与已配置的根键。",
|
|
357
|
+
{
|
|
358
|
+
"type": "object",
|
|
359
|
+
"properties": {},
|
|
360
|
+
"additionalProperties": False,
|
|
361
|
+
},
|
|
362
|
+
self.roots_info,
|
|
363
|
+
False,
|
|
364
|
+
False,
|
|
365
|
+
),
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
def _wrap_sync(self, handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
369
|
+
def wrapped(**kwargs: Any) -> Any:
|
|
370
|
+
result = handler(**kwargs)
|
|
371
|
+
self.sync_to_registry()
|
|
372
|
+
return result
|
|
373
|
+
|
|
374
|
+
return wrapped
|
|
375
|
+
|
|
376
|
+
def _tool_name(self, short: str) -> str:
|
|
377
|
+
return f"{self._prefix}__{short}"
|
|
378
|
+
|
|
379
|
+
def _skill_tool_name(self, skill_ref: str, decl_name: str) -> str:
|
|
380
|
+
safe_ref = skill_ref.replace("/", "_")
|
|
381
|
+
return f"{self._prefix}__{safe_ref}__{decl_name}"
|
|
382
|
+
|
|
383
|
+
def _build_skill_tools(
|
|
384
|
+
self,
|
|
385
|
+
skill_ref: str,
|
|
386
|
+
decls: tuple[SkillToolDecl, ...],
|
|
387
|
+
) -> list[Tool]:
|
|
388
|
+
tools: list[Tool] = []
|
|
389
|
+
seen: set[str] = set()
|
|
390
|
+
for decl in decls:
|
|
391
|
+
api_name = self._skill_tool_name(skill_ref, decl.name)
|
|
392
|
+
if api_name in seen:
|
|
393
|
+
raise ValueError(f"{skill_ref} 内工具名重复: {decl.name}")
|
|
394
|
+
seen.add(api_name)
|
|
395
|
+
for other_ref, other_tools in self._skill_tools.items():
|
|
396
|
+
if other_ref == skill_ref:
|
|
397
|
+
continue
|
|
398
|
+
if any(tool.name == api_name for tool in other_tools):
|
|
399
|
+
raise ValueError(f"工具名已被其他 skill 占用: {api_name}")
|
|
400
|
+
resolved = self._builtin.resolve(decl.handler)
|
|
401
|
+
if resolved is None:
|
|
402
|
+
raise ValueError(
|
|
403
|
+
f"{skill_ref} 的 {decl.name} 引用未知 handler: {decl.handler}"
|
|
404
|
+
)
|
|
405
|
+
tools.append(
|
|
406
|
+
Tool(
|
|
407
|
+
name=api_name,
|
|
408
|
+
description=resolved.description,
|
|
409
|
+
parameters=resolved.parameters,
|
|
410
|
+
handler=resolved.handler,
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
return tools
|
|
414
|
+
|
|
415
|
+
def _load(self, skill_ref: str) -> LoadedSkill:
|
|
416
|
+
ref = self._normalize_ref(skill_ref)
|
|
417
|
+
cached = self._loaded.get(ref)
|
|
418
|
+
if cached is not None:
|
|
419
|
+
return cached
|
|
420
|
+
self._require_available(ref)
|
|
421
|
+
text = catalog.load_skill_text(self._sandbox, ref)
|
|
422
|
+
meta, body = split_frontmatter(text)
|
|
423
|
+
summary = self._available[ref]
|
|
424
|
+
decls = tuple(parse_tool_declarations(text))
|
|
425
|
+
loaded = LoadedSkill(
|
|
426
|
+
summary=summary,
|
|
427
|
+
text=text,
|
|
428
|
+
meta=meta,
|
|
429
|
+
body=body,
|
|
430
|
+
tool_decls=decls,
|
|
431
|
+
)
|
|
432
|
+
self._loaded[ref] = loaded
|
|
433
|
+
return loaded
|
|
434
|
+
|
|
435
|
+
def _activate_skill_for_run(self, skill_ref: str) -> None:
|
|
436
|
+
"""为当前 ReAct 运行启用 skill 并注入正文(计划终稿步预载,不经工具调用)。"""
|
|
437
|
+
ref = self._normalize_ref(skill_ref)
|
|
438
|
+
if ref in self._run_context_refs:
|
|
439
|
+
return
|
|
440
|
+
self._require_available(ref)
|
|
441
|
+
loaded = self._load(ref)
|
|
442
|
+
tools = self._build_skill_tools(ref, loaded.tool_decls)
|
|
443
|
+
self._skill_tools[ref] = tools
|
|
444
|
+
self._enabled.add(ref)
|
|
445
|
+
if self._run is not None:
|
|
446
|
+
self._run_enabled_refs.add(ref)
|
|
447
|
+
self._run_context_refs.add(ref)
|
|
448
|
+
self._append_run_skill_context(ref, loaded.text)
|
|
449
|
+
self.sync_to_registry()
|
|
450
|
+
|
|
451
|
+
def _disable_unlocked(self, skill_ref: str) -> None:
|
|
452
|
+
self._enabled.discard(skill_ref)
|
|
453
|
+
self._skill_tools.pop(skill_ref, None)
|
|
454
|
+
self._run_enabled_refs.discard(skill_ref)
|
|
455
|
+
|
|
456
|
+
def _append_run_skill_context(self, skill_ref: str, text: str) -> None:
|
|
457
|
+
if self._run is None:
|
|
458
|
+
return
|
|
459
|
+
block = f"## 技能 {skill_ref}\n\n{text.strip()}"
|
|
460
|
+
existing = self._run.ephemeral_skill_context.strip()
|
|
461
|
+
if existing:
|
|
462
|
+
self._run.ephemeral_skill_context = f"{existing}\n\n{block}"
|
|
463
|
+
else:
|
|
464
|
+
self._run.ephemeral_skill_context = block
|
|
465
|
+
|
|
466
|
+
def _rebuild_run_skill_context(self) -> None:
|
|
467
|
+
if self._run is None:
|
|
468
|
+
return
|
|
469
|
+
parts: list[str] = []
|
|
470
|
+
for ref in sorted(self._run_context_refs):
|
|
471
|
+
loaded = self._loaded.get(ref)
|
|
472
|
+
if loaded is None:
|
|
473
|
+
loaded = self._load(ref)
|
|
474
|
+
parts.append(f"## 技能 {ref}\n\n{loaded.text.strip()}")
|
|
475
|
+
self._run.ephemeral_skill_context = "\n\n".join(parts)
|
|
476
|
+
|
|
477
|
+
def _require_available(self, skill_ref: str) -> None:
|
|
478
|
+
if skill_ref not in self._available:
|
|
479
|
+
raise ValueError(f"未找到 skill: {skill_ref}")
|
|
480
|
+
|
|
481
|
+
def _normalize_ref(self, skill_ref: str) -> str:
|
|
482
|
+
cleaned = skill_ref.strip().strip("/")
|
|
483
|
+
if not cleaned or ".." in cleaned.split("/"):
|
|
484
|
+
raise ValueError(f"skill_ref 非法: {skill_ref!r}")
|
|
485
|
+
self._sandbox.parse_ref(cleaned)
|
|
486
|
+
return cleaned
|
ai_agent/skill/models.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from ai_agent.skill.catalog import SkillSummary
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class SkillToolDecl:
|
|
10
|
+
"""技能 frontmatter 中声明的一项子工具。"""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
"""注册到工具表时的短名(不含技能前缀)。"""
|
|
14
|
+
handler: str
|
|
15
|
+
"""解析方式,仅允许内置引用形式。"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class LoadedSkill:
|
|
20
|
+
"""已读取全文并完成 frontmatter 解析的技能。"""
|
|
21
|
+
|
|
22
|
+
summary: SkillSummary
|
|
23
|
+
"""扫描摘要(根键、目录名、展示名与描述)。"""
|
|
24
|
+
text: str
|
|
25
|
+
"""技能文件全文。"""
|
|
26
|
+
meta: dict[str, str]
|
|
27
|
+
"""frontmatter 键值。"""
|
|
28
|
+
body: str
|
|
29
|
+
"""正文 Markdown。"""
|
|
30
|
+
tool_decls: tuple[SkillToolDecl, ...]
|
|
31
|
+
"""声明的子工具列表。"""
|
ai_agent/skill/roots.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SkillRootsSandbox:
|
|
8
|
+
"""
|
|
9
|
+
将相对路径限定在若干已命名的技能根目录内。
|
|
10
|
+
|
|
11
|
+
引用须为 ``{root_key}/{skill_id}`` 两段,skill_id 对应根下子文件夹。
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
roots: 根键到目录绝对路径的映射;至少一项
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, roots: Mapping[str, Path | str]) -> None:
|
|
18
|
+
if not roots:
|
|
19
|
+
raise ValueError("至少需要一个 skill 根目录")
|
|
20
|
+
self._roots: dict[str, Path] = {}
|
|
21
|
+
for key, raw in roots.items():
|
|
22
|
+
label = key.strip()
|
|
23
|
+
if not label:
|
|
24
|
+
raise ValueError("root 名称不能为空")
|
|
25
|
+
if label in self._roots:
|
|
26
|
+
raise ValueError(f"重复的 root 名称: {label}")
|
|
27
|
+
root = Path(raw).expanduser().resolve()
|
|
28
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
if not root.is_dir():
|
|
30
|
+
raise ValueError(f"skill 根须为目录: {label}")
|
|
31
|
+
self._roots[label] = root
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def root_keys(self) -> tuple[str, ...]:
|
|
35
|
+
return tuple(self._roots.keys())
|
|
36
|
+
|
|
37
|
+
def root_path(self, root_key: str) -> Path:
|
|
38
|
+
"""返回某根目录的绝对路径(供应用侧使用,勿写入模型可见文案)。"""
|
|
39
|
+
return self._roots[self._require_root(root_key)]
|
|
40
|
+
|
|
41
|
+
def require_root(self, root_key: str) -> str:
|
|
42
|
+
"""校验并返回根键。"""
|
|
43
|
+
return self._require_root(root_key)
|
|
44
|
+
|
|
45
|
+
def _require_root(self, root_key: str) -> str:
|
|
46
|
+
cleaned = root_key.strip()
|
|
47
|
+
if cleaned not in self._roots:
|
|
48
|
+
known = ", ".join(sorted(self._roots))
|
|
49
|
+
raise ValueError(f"未知 root: {root_key}(可用: {known})")
|
|
50
|
+
return cleaned
|
|
51
|
+
|
|
52
|
+
def parse_ref(self, skill_ref: str) -> tuple[str, str, Path]:
|
|
53
|
+
"""
|
|
54
|
+
解析 skill 引用为 (root_key, skill_id, skill_dir)。
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
skill_ref: ``{root_key}/{skill_id}`` 形式
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
根键、技能目录名、技能目录绝对路径
|
|
61
|
+
"""
|
|
62
|
+
root_key, skill_id = self._split_skill_ref(skill_ref)
|
|
63
|
+
return root_key, skill_id, self._skill_dir(root_key, skill_id)
|
|
64
|
+
|
|
65
|
+
def _split_skill_ref(self, skill_ref: str) -> tuple[str, str]:
|
|
66
|
+
cleaned = skill_ref.strip().strip("/")
|
|
67
|
+
if not cleaned:
|
|
68
|
+
raise ValueError("skill_ref 不能为空")
|
|
69
|
+
parts = cleaned.split("/")
|
|
70
|
+
if len(parts) != 2:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
"skill_ref 须为 {root_key}/{skill_id} 形式,例如 project/demo-skill"
|
|
73
|
+
)
|
|
74
|
+
root_key = self._require_root(parts[0])
|
|
75
|
+
skill_id = parts[1].strip()
|
|
76
|
+
if not skill_id or skill_id in (".", ".."):
|
|
77
|
+
raise ValueError(f"非法 skill_id: {parts[1]}")
|
|
78
|
+
return root_key, skill_id
|
|
79
|
+
|
|
80
|
+
def _skill_dir(self, root_key: str, skill_id: str) -> Path:
|
|
81
|
+
root = self._roots[root_key]
|
|
82
|
+
target = (root / skill_id).resolve()
|
|
83
|
+
try:
|
|
84
|
+
target.relative_to(root)
|
|
85
|
+
except ValueError as exc:
|
|
86
|
+
raise ValueError(f"skill_id 越出根目录: {skill_id}") from exc
|
|
87
|
+
return target
|
|
88
|
+
|
|
89
|
+
def resolve_path(self, skill_ref: str, rel_path: str = "") -> Path:
|
|
90
|
+
"""
|
|
91
|
+
解析技能目录内的相对路径。
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
skill_ref: ``{root_key}/{skill_id}``
|
|
95
|
+
rel_path: 相对技能目录的路径;空则返回技能目录本身
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
解析后的绝对路径
|
|
99
|
+
"""
|
|
100
|
+
root_key, skill_id = self._split_skill_ref(skill_ref)
|
|
101
|
+
skill_root = self._skill_dir(root_key, skill_id)
|
|
102
|
+
if not rel_path.strip():
|
|
103
|
+
return skill_root
|
|
104
|
+
cleaned = rel_path.strip().lstrip("/")
|
|
105
|
+
if Path(cleaned).is_absolute():
|
|
106
|
+
raise ValueError("rel_path 须为相对路径")
|
|
107
|
+
if ".." in Path(cleaned).parts:
|
|
108
|
+
raise ValueError(f"非法 rel_path: {rel_path}")
|
|
109
|
+
target = (skill_root / cleaned).resolve()
|
|
110
|
+
try:
|
|
111
|
+
target.relative_to(skill_root)
|
|
112
|
+
except ValueError as exc:
|
|
113
|
+
raise ValueError(f"路径越出技能目录: {rel_path}") from exc
|
|
114
|
+
return target
|
|
115
|
+
|
|
116
|
+
def skill_md_path(self, skill_ref: str) -> Path:
|
|
117
|
+
"""返回 SKILL.md 的绝对路径。"""
|
|
118
|
+
root_key, skill_id = self._split_skill_ref(skill_ref)
|
|
119
|
+
return self._skill_dir(root_key, skill_id) / "SKILL.md"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def normalize_skill_roots(
|
|
123
|
+
roots: Mapping[str, Path | str] | Sequence[Path | str] | Path | str,
|
|
124
|
+
) -> dict[str, Path | str]:
|
|
125
|
+
"""
|
|
126
|
+
将多种入参形态规范为 ``{root_key: path}``。
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
roots: 映射、路径列表或单一路径
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
根键到路径的映射
|
|
133
|
+
"""
|
|
134
|
+
if isinstance(roots, Mapping):
|
|
135
|
+
return dict(roots)
|
|
136
|
+
if isinstance(roots, (str, Path)):
|
|
137
|
+
path = Path(roots)
|
|
138
|
+
label = path.name or "skills"
|
|
139
|
+
return {label: roots}
|
|
140
|
+
items = list(roots)
|
|
141
|
+
if not items:
|
|
142
|
+
raise ValueError("skill 根目录列表不能为空")
|
|
143
|
+
result: dict[str, Path | str] = {}
|
|
144
|
+
for index, raw in enumerate(items):
|
|
145
|
+
path = Path(raw)
|
|
146
|
+
label = path.name or f"root_{index}"
|
|
147
|
+
if label in result:
|
|
148
|
+
label = f"{label}_{index}"
|
|
149
|
+
result[label] = raw
|
|
150
|
+
return result
|