feat(skills): add progressive skill loading with disclosure_level=0 (U5)
When disclosure_level=0, system prompt only injects skill name + description (summary mode). SkillDetailTool is injected into the tool set, allowing the LLM to load full instructions on-demand via skill_detail(query). This reduces context window consumption when many skills are registered.
This commit is contained in:
parent
dfd188b1a4
commit
fa152e24ac
|
|
@ -211,6 +211,14 @@ class RequestPreprocessor:
|
|||
skill_prompt = build_skill_system_prompt(skill_config)
|
||||
execution_mode = _resolve_execution_mode(skill_config)
|
||||
|
||||
# U5: 渐进式技能加载 — disclosure_level == 0 时注入 skill_detail 工具
|
||||
merged_tools = list(skill_tools)
|
||||
if getattr(skill_config, "disclosure_level", 1) == 0:
|
||||
from agentkit.skills.skill_detail import SkillDetailTool
|
||||
|
||||
if not any(t.name == "skill_detail" for t in merged_tools):
|
||||
merged_tools.append(SkillDetailTool(skill_registry=registry))
|
||||
|
||||
return SkillRoutingResult(
|
||||
clean_content=clean_content,
|
||||
matched=True,
|
||||
|
|
@ -222,7 +230,7 @@ class RequestPreprocessor:
|
|||
agent_name=skill_name,
|
||||
model=model,
|
||||
system_prompt=skill_prompt,
|
||||
tools=skill_tools,
|
||||
tools=merged_tools,
|
||||
execution_mode=execution_mode,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -103,8 +103,37 @@ def build_skill_system_prompt(skill_config) -> str | None:
|
|||
|
||||
v7: 若 skill_config.preconditions 非空,在基础 prompt 后追加
|
||||
## Activation Preconditions 段落(软检查,见 KTD1)。
|
||||
|
||||
U5: 渐进式技能加载 — 当 disclosure_level == 0 时,只注入 skill 名称 + 描述
|
||||
(概要模式)。LLM 可调用 skill_detail 工具按需加载完整 instructions。
|
||||
disclosure_level >= 1(默认)时行为不变(全量加载),向后兼容。
|
||||
"""
|
||||
if not skill_config or not skill_config.prompt:
|
||||
if not skill_config:
|
||||
return None
|
||||
|
||||
# U5: Level 0 — 概要模式,只注入 name + description
|
||||
disclosure_level = getattr(skill_config, "disclosure_level", 1)
|
||||
if disclosure_level == 0:
|
||||
name = getattr(skill_config, "name", "unknown")
|
||||
description = getattr(skill_config, "description", "")
|
||||
summary = f"## Skill: {name}\n{description}" if description else f"## Skill: {name}"
|
||||
# 安全守卫:前置条件即使在概要模式下也必须注入
|
||||
preconditions = getattr(skill_config, "preconditions", None)
|
||||
if preconditions:
|
||||
lines = ["## Activation Preconditions", "Before executing this skill, verify:"]
|
||||
lines.extend(f"- {p}" for p in preconditions)
|
||||
lines.append(
|
||||
"If any precondition is not met, refuse to execute or ask the user for clarification."
|
||||
)
|
||||
return f"{summary}\n\n" + "\n".join(lines)
|
||||
# 提示 LLM 可通过 skill_detail 工具加载完整 instructions
|
||||
return (
|
||||
f"{summary}\n\n"
|
||||
"*(Call the skill_detail tool with this skill name to load full instructions)*"
|
||||
)
|
||||
|
||||
# Level 1+: 全量加载(现有行为)
|
||||
if not skill_config.prompt:
|
||||
return None
|
||||
prompt_parts = []
|
||||
for key in ("identity", "context", "instructions", "constraints", "output_format"):
|
||||
|
|
@ -183,6 +212,13 @@ async def resolve_skill_routing(
|
|||
if tool.name not in seen_names:
|
||||
seen_names.add(tool.name)
|
||||
merged_tools.append(tool)
|
||||
# U5: 渐进式技能加载 — disclosure_level == 0 时注入 skill_detail 工具
|
||||
if getattr(result.skill_config, "disclosure_level", 1) == 0 and skill_registry is not None:
|
||||
from agentkit.skills.skill_detail import SkillDetailTool
|
||||
|
||||
if "skill_detail" not in seen_names:
|
||||
merged_tools.append(SkillDetailTool(skill_registry=skill_registry))
|
||||
seen_names.add("skill_detail")
|
||||
result.tools = merged_tools
|
||||
|
||||
result.model = (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
"""SkillDetailTool — 渐进式技能加载工具 (U5)
|
||||
|
||||
当 skill_config.disclosure_level == 0 时,system prompt 只注入 skill 名称 + 描述。
|
||||
LLM 可调用此工具按需加载完整 skill instructions(identity/context/instructions/
|
||||
constraints/output_format),从本地 SkillRegistry 检索。
|
||||
|
||||
与 tools/skill_search.py 的 SkillSearchTool 区别:
|
||||
- SkillSearchTool 搜索 npx 外部市场(安装前发现)
|
||||
- SkillDetailTool 加载本地已注册 skill 的完整内容(执行前加载)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agentkit.tools.base import Tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SkillDetailTool(Tool):
|
||||
"""按需加载本地已注册 skill 的完整 instructions。
|
||||
|
||||
用于渐进式技能加载(disclosure_level=0):system prompt 只含 skill 概要,
|
||||
LLM 决定执行该 skill 时调用此工具获取完整 instructions。
|
||||
|
||||
Usage::
|
||||
|
||||
from agentkit.skills.skill_detail import SkillDetailTool
|
||||
tool = SkillDetailTool(skill_registry=registry)
|
||||
result = await tool.execute(query="research")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
skill_registry: Any,
|
||||
name: str = "skill_detail",
|
||||
description: str = (
|
||||
"Load full instructions for a registered skill by name or keyword. "
|
||||
"Use this when the system prompt only shows a skill summary (name + description) "
|
||||
"and you need the complete instructions to execute the skill. "
|
||||
"Returns the skill's identity, context, instructions, constraints, and output format."
|
||||
),
|
||||
input_schema: dict[str, Any] | None = None,
|
||||
output_schema: dict[str, Any] | None = None,
|
||||
version: str = "1.0.0",
|
||||
tags: list[str] | None = None,
|
||||
):
|
||||
schema = input_schema or {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Skill name or keyword to search for. "
|
||||
"Returns the full instructions of the best-matching registered skill."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
super().__init__(
|
||||
name=name,
|
||||
description=description,
|
||||
input_schema=schema,
|
||||
output_schema=output_schema,
|
||||
version=version,
|
||||
tags=tags or ["skill", "search", "meta"],
|
||||
)
|
||||
self._registry = skill_registry
|
||||
|
||||
async def execute(self, **kwargs) -> dict:
|
||||
"""执行 skill 详情加载。
|
||||
|
||||
Args:
|
||||
query: skill 名称或关键词。
|
||||
|
||||
Returns:
|
||||
包含 ``name``、``description``、``full_instructions`` 的字典;
|
||||
无匹配时返回 ``message`` 提示。
|
||||
"""
|
||||
query = str(kwargs.get("query", "")).strip()
|
||||
if not query:
|
||||
return {
|
||||
"error": "query parameter is required",
|
||||
"results": [],
|
||||
}
|
||||
|
||||
# 精确名称匹配优先
|
||||
try:
|
||||
skill = self._registry.get(query)
|
||||
return self._format_skill_full(skill)
|
||||
except Exception:
|
||||
pass # 非精确匹配,降级到关键词搜索
|
||||
|
||||
# 关键词搜索:匹配 skill 名称和描述
|
||||
matches: list[Any] = []
|
||||
query_lower = query.lower()
|
||||
for skill in self._registry.list_skills():
|
||||
name = skill.name.lower()
|
||||
desc = (skill.config.description or "").lower()
|
||||
if query_lower in name or query_lower in desc:
|
||||
matches.append(skill)
|
||||
|
||||
if not matches:
|
||||
return {
|
||||
"query": query,
|
||||
"count": 0,
|
||||
"results": [],
|
||||
"message": f"No skills matched '{query}'.",
|
||||
}
|
||||
|
||||
# 返回最佳匹配(第一个)
|
||||
return self._format_skill_full(matches[0])
|
||||
|
||||
@staticmethod
|
||||
def _format_skill_full(skill: Any) -> dict[str, Any]:
|
||||
"""格式化 skill 的完整 instructions 供 LLM 使用。"""
|
||||
config = skill.config
|
||||
prompt_parts: list[str] = []
|
||||
for key in ("identity", "context", "instructions", "constraints", "output_format"):
|
||||
val = (config.prompt or {}).get(key)
|
||||
if val:
|
||||
prompt_parts.append(f"### {key.title()}\n{val}")
|
||||
|
||||
# v7: 注入激活前置条件(安全守卫,即使在渐进加载模式下也不应省略)
|
||||
preconditions = getattr(config, "preconditions", None)
|
||||
if preconditions:
|
||||
lines = ["### Activation Preconditions", "Before executing this skill, verify:"]
|
||||
lines.extend(f"- {p}" for p in preconditions)
|
||||
lines.append(
|
||||
"If any precondition is not met, refuse to execute or ask the user for clarification."
|
||||
)
|
||||
prompt_parts.append("\n".join(lines))
|
||||
|
||||
return {
|
||||
"name": config.name,
|
||||
"description": config.description,
|
||||
"version": config.version,
|
||||
"full_instructions": "\n\n".join(prompt_parts) if prompt_parts else "",
|
||||
"tools": [t.name for t in (skill.tools or [])],
|
||||
}
|
||||
|
|
@ -2,15 +2,19 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
import pytest
|
||||
|
||||
from agentkit.chat.skill_routing import (
|
||||
ExecutionMode,
|
||||
SkillRoutingResult,
|
||||
build_skill_system_prompt,
|
||||
)
|
||||
from agentkit.experts.config import ExpertConfig, ExpertTemplate
|
||||
from agentkit.experts.registry import ExpertTemplateRegistry
|
||||
from agentkit.experts.router import ExpertTeamRouter
|
||||
from agentkit.skills.base import Skill, SkillConfig
|
||||
from agentkit.skills.registry import SkillRegistry
|
||||
from agentkit.skills.skill_detail import SkillDetailTool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -118,3 +122,306 @@ class TestSkillRoutingResult:
|
|||
execution_mode=ExecutionMode.DIRECT_CHAT,
|
||||
)
|
||||
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# U5: Progressive Skill Loading Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_skill_config(
|
||||
name: str = "research",
|
||||
description: str = "Research skill for gathering information",
|
||||
disclosure_level: int = 1,
|
||||
preconditions: list[str] | None = None,
|
||||
) -> SkillConfig:
|
||||
"""Create a SkillConfig for testing progressive loading."""
|
||||
return SkillConfig(
|
||||
name=name,
|
||||
agent_type="skill",
|
||||
description=description,
|
||||
task_mode="llm_generate",
|
||||
execution_mode="react",
|
||||
prompt={
|
||||
"identity": f"You are a {name} expert.",
|
||||
"context": "Use this skill when research is needed.",
|
||||
"instructions": "Step 1: gather data. Step 2: analyze.",
|
||||
"constraints": "Do not hallucinate.",
|
||||
"output_format": "Markdown report.",
|
||||
},
|
||||
disclosure_level=disclosure_level,
|
||||
preconditions=preconditions,
|
||||
)
|
||||
|
||||
|
||||
def _make_skill_registry_with_skills(*configs: SkillConfig) -> SkillRegistry:
|
||||
"""Create a SkillRegistry with the given skill configs registered."""
|
||||
registry = SkillRegistry()
|
||||
for config in configs:
|
||||
registry.register(Skill(config=config, tools=[]))
|
||||
return registry
|
||||
|
||||
|
||||
class TestProgressiveSkillLoading:
|
||||
"""U5: 渐进式技能加载 — disclosure_level 控制概要 vs 全量注入。"""
|
||||
|
||||
def test_level_0_summary_only_name_and_description(self) -> None:
|
||||
"""disclosure_level=0 时,system_prompt 只含 skill name + description。"""
|
||||
config = _make_skill_config(
|
||||
name="research",
|
||||
description="Research skill for gathering information",
|
||||
disclosure_level=0,
|
||||
)
|
||||
prompt = build_skill_system_prompt(config)
|
||||
|
||||
assert prompt is not None
|
||||
assert "## Skill: research" in prompt
|
||||
assert "Research skill for gathering information" in prompt
|
||||
# Should NOT contain full prompt sections
|
||||
assert "You are a research expert" not in prompt
|
||||
assert "Step 1: gather data" not in prompt
|
||||
assert "Do not hallucinate" not in prompt
|
||||
# Should hint at skill_detail tool
|
||||
assert "skill_detail" in prompt
|
||||
|
||||
def test_level_0_with_preconditions_includes_guard(self) -> None:
|
||||
"""disclosure_level=0 时,前置条件仍然注入(安全守卫)。"""
|
||||
config = _make_skill_config(
|
||||
name="dangerous_op",
|
||||
description="A dangerous operation skill",
|
||||
disclosure_level=0,
|
||||
preconditions=["User must confirm the operation", "API key must be set"],
|
||||
)
|
||||
prompt = build_skill_system_prompt(config)
|
||||
|
||||
assert prompt is not None
|
||||
assert "## Skill: dangerous_op" in prompt
|
||||
assert "## Activation Preconditions" in prompt
|
||||
assert "User must confirm the operation" in prompt
|
||||
assert "API key must be set" in prompt
|
||||
|
||||
def test_level_1_full_prompt_backward_compatible(self) -> None:
|
||||
"""disclosure_level=1(默认)时,行为与现状一致(全量加载)。"""
|
||||
config = _make_skill_config(
|
||||
name="research",
|
||||
description="Research skill",
|
||||
disclosure_level=1,
|
||||
)
|
||||
prompt = build_skill_system_prompt(config)
|
||||
|
||||
assert prompt is not None
|
||||
assert "You are a research expert." in prompt
|
||||
assert "Step 1: gather data. Step 2: analyze." in prompt
|
||||
assert "Do not hallucinate." in prompt
|
||||
assert "Markdown report." in prompt
|
||||
# Should NOT contain the skill_detail hint
|
||||
assert "skill_detail" not in prompt
|
||||
|
||||
def test_level_0_no_description_still_works(self) -> None:
|
||||
"""disclosure_level=0 且无 description 时,仍能生成有效 prompt。"""
|
||||
config = _make_skill_config(
|
||||
name="minimal",
|
||||
description="",
|
||||
disclosure_level=0,
|
||||
)
|
||||
prompt = build_skill_system_prompt(config)
|
||||
|
||||
assert prompt is not None
|
||||
assert "## Skill: minimal" in prompt
|
||||
assert "skill_detail" in prompt
|
||||
|
||||
|
||||
class TestSkillDetailTool:
|
||||
"""U5: SkillDetailTool — 按需加载完整 skill instructions。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_name_match_returns_full_instructions(self) -> None:
|
||||
"""精确名称匹配 → 返回完整 instructions。"""
|
||||
config = _make_skill_config(name="research", disclosure_level=0)
|
||||
registry = _make_skill_registry_with_skills(config)
|
||||
|
||||
tool = SkillDetailTool(skill_registry=registry)
|
||||
result = await tool.execute(query="research")
|
||||
|
||||
assert result["name"] == "research"
|
||||
assert result["description"] == "Research skill for gathering information"
|
||||
assert "You are a research expert." in result["full_instructions"]
|
||||
assert "Step 1: gather data" in result["full_instructions"]
|
||||
assert "Do not hallucinate." in result["full_instructions"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keyword_search_returns_matching_skill(self) -> None:
|
||||
"""关键词搜索 → 返回匹配的 skill 完整内容。"""
|
||||
config1 = _make_skill_config(name="research", description="Research skill")
|
||||
config2 = _make_skill_config(
|
||||
name="code_review",
|
||||
description="Review code quality",
|
||||
)
|
||||
registry = _make_skill_registry_with_skills(config1, config2)
|
||||
|
||||
tool = SkillDetailTool(skill_registry=registry)
|
||||
result = await tool.execute(query="review")
|
||||
|
||||
assert result["name"] == "code_review"
|
||||
assert "Review code quality" in result["description"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_match_returns_message(self) -> None:
|
||||
"""无匹配 → 返回 'No skills matched' 提示。"""
|
||||
config = _make_skill_config(name="research")
|
||||
registry = _make_skill_registry_with_skills(config)
|
||||
|
||||
tool = SkillDetailTool(skill_registry=registry)
|
||||
result = await tool.execute(query="nonexistent_skill")
|
||||
|
||||
assert result["count"] == 0
|
||||
assert result["results"] == []
|
||||
assert "No skills matched" in result["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_query_returns_error(self) -> None:
|
||||
"""空 query → 返回错误。"""
|
||||
config = _make_skill_config(name="research")
|
||||
registry = _make_skill_registry_with_skills(config)
|
||||
|
||||
tool = SkillDetailTool(skill_registry=registry)
|
||||
result = await tool.execute(query="")
|
||||
|
||||
assert "error" in result
|
||||
assert "query parameter is required" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_includes_preconditions_in_full_instructions(self) -> None:
|
||||
"""完整 instructions 中包含前置条件(安全守卫)。"""
|
||||
config = _make_skill_config(
|
||||
name="dangerous_op",
|
||||
preconditions=["User must confirm", "API key set"],
|
||||
disclosure_level=0,
|
||||
)
|
||||
registry = _make_skill_registry_with_skills(config)
|
||||
|
||||
tool = SkillDetailTool(skill_registry=registry)
|
||||
result = await tool.execute(query="dangerous_op")
|
||||
|
||||
assert "### Activation Preconditions" in result["full_instructions"]
|
||||
assert "User must confirm" in result["full_instructions"]
|
||||
assert "API key set" in result["full_instructions"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_case_insensitive_keyword_search(self) -> None:
|
||||
"""关键词搜索大小写不敏感。"""
|
||||
config = _make_skill_config(name="Research", description="Research skill")
|
||||
registry = _make_skill_registry_with_skills(config)
|
||||
|
||||
tool = SkillDetailTool(skill_registry=registry)
|
||||
result = await tool.execute(query="RESEARCH")
|
||||
|
||||
assert result["name"] == "Research"
|
||||
|
||||
|
||||
class TestResolveSkillRoutingProgressive:
|
||||
"""U5: resolve_skill_routing 在 disclosure_level=0 时注入 skill_detail 工具。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_level_0_adds_skill_detail_tool(self) -> None:
|
||||
"""disclosure_level=0 时,routing 结果包含 skill_detail 工具。"""
|
||||
from agentkit.chat.skill_routing import resolve_skill_routing
|
||||
|
||||
config = _make_skill_config(name="research", disclosure_level=0)
|
||||
registry = _make_skill_registry_with_skills(config)
|
||||
|
||||
result = await resolve_skill_routing(
|
||||
content="@skill:research analyze the data",
|
||||
skill_registry=registry,
|
||||
default_tools=[],
|
||||
default_system_prompt="default",
|
||||
)
|
||||
|
||||
assert result.matched is True
|
||||
assert result.skill_name == "research"
|
||||
tool_names = [t.name for t in result.tools]
|
||||
assert "skill_detail" in tool_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_level_1_does_not_add_skill_detail_tool(self) -> None:
|
||||
"""disclosure_level=1(默认)时,不注入 skill_detail 工具(向后兼容)。"""
|
||||
from agentkit.chat.skill_routing import resolve_skill_routing
|
||||
|
||||
config = _make_skill_config(name="research", disclosure_level=1)
|
||||
registry = _make_skill_registry_with_skills(config)
|
||||
|
||||
result = await resolve_skill_routing(
|
||||
content="@skill:research analyze the data",
|
||||
skill_registry=registry,
|
||||
default_tools=[],
|
||||
default_system_prompt="default",
|
||||
)
|
||||
|
||||
assert result.matched is True
|
||||
tool_names = [t.name for t in result.tools]
|
||||
assert "skill_detail" not in tool_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_level_0_system_prompt_is_summary(self) -> None:
|
||||
"""disclosure_level=0 时,system_prompt 是概要模式。"""
|
||||
from agentkit.chat.skill_routing import resolve_skill_routing
|
||||
|
||||
config = _make_skill_config(name="research", disclosure_level=0)
|
||||
registry = _make_skill_registry_with_skills(config)
|
||||
|
||||
result = await resolve_skill_routing(
|
||||
content="@skill:research analyze the data",
|
||||
skill_registry=registry,
|
||||
default_tools=[],
|
||||
default_system_prompt="default",
|
||||
)
|
||||
|
||||
assert result.system_prompt is not None
|
||||
assert "## Skill: research" in result.system_prompt
|
||||
# Full instructions should NOT be in the system prompt
|
||||
assert "You are a research expert." not in result.system_prompt
|
||||
|
||||
|
||||
class TestRequestPreprocessorProgressive:
|
||||
"""U5: RequestPreprocessor._resolve_explicit_skill 在 disclosure_level=0 时注入 skill_detail。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preprocessor_level_0_adds_skill_detail(self) -> None:
|
||||
"""RequestPreprocessor 在 disclosure_level=0 时注入 skill_detail 工具。"""
|
||||
from agentkit.chat.request_preprocessor import RequestPreprocessor
|
||||
|
||||
config = _make_skill_config(name="research", disclosure_level=0)
|
||||
registry = _make_skill_registry_with_skills(config)
|
||||
|
||||
preprocessor = RequestPreprocessor()
|
||||
result = await preprocessor.preprocess(
|
||||
content="@skill:research analyze the data",
|
||||
skill_registry=registry,
|
||||
default_tools=[],
|
||||
default_system_prompt="default",
|
||||
)
|
||||
|
||||
assert result.matched is True
|
||||
assert result.skill_name == "research"
|
||||
tool_names = [t.name for t in result.tools]
|
||||
assert "skill_detail" in tool_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preprocessor_level_1_no_skill_detail(self) -> None:
|
||||
"""RequestPreprocessor 在 disclosure_level=1 时不注入 skill_detail(向后兼容)。"""
|
||||
from agentkit.chat.request_preprocessor import RequestPreprocessor
|
||||
|
||||
config = _make_skill_config(name="research", disclosure_level=1)
|
||||
registry = _make_skill_registry_with_skills(config)
|
||||
|
||||
preprocessor = RequestPreprocessor()
|
||||
result = await preprocessor.preprocess(
|
||||
content="@skill:research analyze the data",
|
||||
skill_registry=registry,
|
||||
default_tools=[],
|
||||
default_system_prompt="default",
|
||||
)
|
||||
|
||||
assert result.matched is True
|
||||
tool_names = [t.name for t in result.tools]
|
||||
assert "skill_detail" not in tool_names
|
||||
|
|
|
|||
Loading…
Reference in New Issue