refactor(agents): prompt 外置方案A — 21 prompt 散文外迁 .md + SpecResolver
把 21 个内置 agent 的 system_prompt 从 specs.py 的 Python 常量外置为 prompts/<spec.name>.md,import 期由 load_prompt 确定性加载;建立 SPECS 名册 + SCHEMA_CATALOG(Pydantic 类型留 Python)+ 统一只读解析入口 SpecResolver。 纯重构、零功能/schema 变更,缓存断点前块字节级不变(不变量 #9)。 @llm packages/agents(步骤1-3) - spec_model.py:抽出 AgentSpec(frozen,字段不变) - prompt_loader.py:load_prompt = utf-8-sig 去BOM → LF 归一 → NFC → rstrip尾LF, 内存缓存 + fail-fast(PromptNotFoundError),import 期确定性 - schema_catalog.py:SCHEMA_CATALOG[name]→output type 唯一真相源(refiner=None) - prompts/*.md ×21:取常量「运行时值」程序化外迁(反斜杠折行已塌缩, 物理换行≡运行时换行);文件名按 spec.name 连字符(style.md/character-gen.md 等) - specs.py:删 21 常量 + AgentSpec 类;system_prompt=load_prompt(name)、 output_schema=SCHEMA_CATALOG[name];建 SPECS + REVIEW_RESERVED_NAMES; *_spec 兼容期保留且 SPECS[name] is *_spec(同一实例)。804→337 行 - __init__.py:显式 __all__ 重导出(避 F401) @backend packages/skills(步骤4-5) - SpecResolver:内置 SPECS(纯内存、零 DB)+ 用户 SkillRegistry 统一 get; 内置 name 永不触发 DB;output_schema_for 精确匹配 - skill_registry:保留命名空间守卫前移至入库校验,拒同名内置 → VALIDATION - toolbox_registry:GeneratorTool.spec 改走 SPECS[...],删 12 个 *_spec 直接 import @devops repo-root - .gitattributes:prompts/*.md text eol=lf(修正:须用完整嵌套路径才匹配) - packages/agents/pyproject:hatchling artifacts 纳入 prompts/*.md 随 wheel/sdist 分发 - ci.yml:新增 build wheel → 裸装 → import ww_agents.SPECS 冒烟 TDD 全程 mock 网关;门禁绿:ruff/format clean · mypy 209 files · pytest 744 passed (含金标准 sha256 回归 / md↔spec↔catalog 一一对应 / fail-fast / BOM+NFC / 内置守卫 / 同一实例 / 编排器无回归 / apps/api import-smoke / 打包冒烟)
This commit is contained in:
@@ -27,8 +27,8 @@ def _spec(*, reads: list[str], writes: list[str], scope: str = "custom") -> Agen
|
||||
name="custom_skill",
|
||||
tier="writer",
|
||||
system_prompt="x",
|
||||
reads=reads,
|
||||
writes=writes,
|
||||
reads=tuple(reads),
|
||||
writes=tuple(writes),
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,16 +20,16 @@ def _record(
|
||||
name: str,
|
||||
*,
|
||||
scope: str = "custom",
|
||||
reads: list[str] | None = None,
|
||||
writes: list[str] | None = None,
|
||||
reads: tuple[str, ...] = (),
|
||||
writes: tuple[str, ...] = (),
|
||||
) -> SkillRecord:
|
||||
return SkillRecord(
|
||||
name=name,
|
||||
scope=scope,
|
||||
tier="writer",
|
||||
system_prompt=f"prompt for {name}",
|
||||
reads=reads or [],
|
||||
writes=writes or [],
|
||||
reads=reads,
|
||||
writes=writes,
|
||||
genre=None,
|
||||
)
|
||||
|
||||
@@ -49,10 +49,10 @@ async def test_load_builds_specs_keyed_by_name() -> None:
|
||||
_record(
|
||||
"worldgen",
|
||||
scope="builtin",
|
||||
reads=["world_entities"],
|
||||
writes=["world_entities"],
|
||||
reads=("world_entities",),
|
||||
writes=("world_entities",),
|
||||
),
|
||||
_record("cpgen", scope="custom", reads=["characters"]),
|
||||
_record("cpgen", scope="custom", reads=("characters",)),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -62,7 +62,7 @@ async def test_load_builds_specs_keyed_by_name() -> None:
|
||||
spec = registry.get("worldgen")
|
||||
assert isinstance(spec, AgentSpec)
|
||||
assert spec.tier == "writer"
|
||||
assert spec.reads == ["world_entities"]
|
||||
assert spec.reads == ("world_entities",)
|
||||
assert spec.scope == "builtin"
|
||||
|
||||
|
||||
@@ -95,9 +95,54 @@ async def test_list_scope_filters_by_scope() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_rejects_over_permission_skill() -> None:
|
||||
# 越权声明(reads 指向未知表)→ 加载即拒绝(守 §5.6),不静默入册。
|
||||
repo = _FakeSkillRepo([_record("evil", reads=["secret_table"])])
|
||||
repo = _FakeSkillRepo([_record("evil", reads=("secret_table",))])
|
||||
|
||||
with pytest.raises(AppError) as exc:
|
||||
await SkillRegistry.load(repo)
|
||||
|
||||
assert exc.value.code is ErrorCode.VALIDATION
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_rejects_skill_colliding_with_reserved_review_name() -> None:
|
||||
# 用户 skill 偷用四审保留名(continuity)→ 入库即拒(守卫前移,不变量 #3)。
|
||||
repo = _FakeSkillRepo([_record("continuity", reads=("chapter_digests",))])
|
||||
|
||||
with pytest.raises(AppError) as exc:
|
||||
await SkillRegistry.load(repo)
|
||||
|
||||
assert exc.value.code is ErrorCode.VALIDATION
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_rejects_skill_colliding_with_builtin_name() -> None:
|
||||
# 用户 skill 冒用非四审的内置 name(brainstorm)→ 入库即拒。
|
||||
repo = _FakeSkillRepo([_record("brainstorm", reads=("projects",))])
|
||||
|
||||
with pytest.raises(AppError) as exc:
|
||||
await SkillRegistry.load(repo)
|
||||
|
||||
assert exc.value.code is ErrorCode.VALIDATION
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_build_does_not_raise_on_reserved_collision() -> None:
|
||||
# 守卫在入库期(load),resolver.build 纯合并不校验——不得因同名抛错。
|
||||
from ww_skills import SpecResolver
|
||||
|
||||
empty = await SkillRegistry.load(_FakeSkillRepo([]))
|
||||
|
||||
resolver = SpecResolver.build(empty) # 不抛
|
||||
|
||||
assert resolver.get("continuity") is not None
|
||||
|
||||
|
||||
def test_skill_record_reads_writes_are_immutable_tuples() -> None:
|
||||
# frozen 只防整字段重绑,不防 list 原地变异——reads/writes 必须是 tuple(无 append)。
|
||||
record = _record("immut", reads=("characters",), writes=("world_entities",))
|
||||
|
||||
assert isinstance(record.reads, tuple)
|
||||
assert isinstance(record.writes, tuple)
|
||||
assert record.reads == ("characters",)
|
||||
assert record.writes == ("world_entities",)
|
||||
assert not hasattr(record.reads, "append")
|
||||
|
||||
149
packages/skills/tests/test_spec_resolver.py
Normal file
149
packages/skills/tests/test_spec_resolver.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""SpecResolver 单测(Prompt 外置方案A · 步4)。
|
||||
|
||||
`SpecResolver` 统一内置(`SPECS`,纯内存、零 DB)与用户 skill(`SkillRegistry`,DB)
|
||||
的只读解析入口。核心约束(不变量 #3 / 评审 HIGH):
|
||||
|
||||
- `get(name)` 先查内置 `SPECS`(纯内存),命中内置 name **绝不触发任何 DB/registry 调用**;
|
||||
- 未命中内置才查 `SkillRegistry`;都无 → `AppError(NOT_FOUND)`;
|
||||
- `output_schema_for`:内置 → `SCHEMA_CATALOG[name]` 真类;纯用户 skill → None;
|
||||
name 精确字符串相等(无大小写/连字符归一),拼错近似 name → None 不报错;
|
||||
- `resolver.build` 纯合并,**不**做保留名冲突校验(守卫前移至 SkillRegistry 入库)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from ww_agents import (
|
||||
SCHEMA_CATALOG,
|
||||
SPECS,
|
||||
AgentSpec,
|
||||
)
|
||||
from ww_shared import AppError, ErrorCode
|
||||
from ww_skills import SpecResolver
|
||||
|
||||
|
||||
class _FakeRegistry:
|
||||
"""断言用 fake:记录 get/names 是否被调用(内置 get 必须零触达)。"""
|
||||
|
||||
def __init__(self, specs: dict[str, AgentSpec] | None = None) -> None:
|
||||
self._specs = dict(specs or {})
|
||||
self.get_calls: list[str] = []
|
||||
self.names_calls: int = 0
|
||||
|
||||
def get(self, name: str) -> AgentSpec:
|
||||
self.get_calls.append(name)
|
||||
spec = self._specs.get(name)
|
||||
if spec is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"skill not found: {name}")
|
||||
return spec
|
||||
|
||||
def names(self) -> list[str]:
|
||||
self.names_calls += 1
|
||||
return sorted(self._specs)
|
||||
|
||||
def list_scope(self, scope: str) -> list[AgentSpec]:
|
||||
return [s for s in sorted(self._specs.values(), key=lambda s: s.name) if s.scope == scope]
|
||||
|
||||
|
||||
def _user_spec(name: str, scope: str = "custom") -> AgentSpec:
|
||||
return AgentSpec(
|
||||
name=name,
|
||||
tier="writer",
|
||||
system_prompt=f"prompt for {name}",
|
||||
input_schema=None,
|
||||
output_schema=None,
|
||||
reads=(),
|
||||
writes=(),
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
|
||||
# ---- #7 resolver 内置/用户对齐 + 零 DB ----
|
||||
|
||||
|
||||
def test_get_builtin_returns_specs_instance_without_touching_registry() -> None:
|
||||
# Arrange:fake registry 本身有同名条目也无所谓——内置命中必须零触达。
|
||||
fake = _FakeRegistry({"continuity": _user_spec("continuity")})
|
||||
resolver = SpecResolver.build(fake)
|
||||
|
||||
# Act
|
||||
spec = resolver.get("continuity")
|
||||
|
||||
# Assert:拿到的是内置同一实例,且 registry.get 从未被调用(零 DB)。
|
||||
assert spec is SPECS["continuity"]
|
||||
assert fake.get_calls == []
|
||||
|
||||
|
||||
def test_get_user_skill_falls_through_to_registry() -> None:
|
||||
fake = _FakeRegistry({"my-skill": _user_spec("my-skill")})
|
||||
resolver = SpecResolver.build(fake)
|
||||
|
||||
spec = resolver.get("my-skill")
|
||||
|
||||
assert spec.name == "my-skill"
|
||||
assert fake.get_calls == ["my-skill"]
|
||||
|
||||
|
||||
def test_get_unknown_everywhere_raises_not_found() -> None:
|
||||
fake = _FakeRegistry()
|
||||
resolver = SpecResolver.build(fake)
|
||||
|
||||
with pytest.raises(AppError) as exc:
|
||||
resolver.get("nope-not-here")
|
||||
|
||||
assert exc.value.code is ErrorCode.NOT_FOUND
|
||||
assert fake.get_calls == ["nope-not-here"]
|
||||
|
||||
|
||||
def test_names_merges_builtin_and_user() -> None:
|
||||
fake = _FakeRegistry({"my-skill": _user_spec("my-skill")})
|
||||
resolver = SpecResolver.build(fake)
|
||||
|
||||
names = resolver.names()
|
||||
|
||||
assert "continuity" in names
|
||||
assert "my-skill" in names
|
||||
|
||||
|
||||
def test_list_scope_filters_builtin_and_user() -> None:
|
||||
fake = _FakeRegistry({"my-skill": _user_spec("my-skill", scope="custom")})
|
||||
resolver = SpecResolver.build(fake)
|
||||
|
||||
builtin = resolver.list_scope("builtin")
|
||||
custom = resolver.list_scope("custom")
|
||||
|
||||
assert all(s.scope == "builtin" for s in builtin)
|
||||
assert {s.name for s in custom} == {"my-skill"}
|
||||
|
||||
|
||||
# ---- #9 output_schema_for ----
|
||||
|
||||
|
||||
def test_output_schema_for_builtin_returns_real_type() -> None:
|
||||
fake = _FakeRegistry()
|
||||
resolver = SpecResolver.build(fake)
|
||||
|
||||
assert resolver.output_schema_for("continuity") is SCHEMA_CATALOG["continuity"]
|
||||
|
||||
|
||||
def test_output_schema_for_refiner_is_none() -> None:
|
||||
fake = _FakeRegistry()
|
||||
resolver = SpecResolver.build(fake)
|
||||
|
||||
assert resolver.output_schema_for("refiner") is None
|
||||
|
||||
|
||||
def test_output_schema_for_pure_user_skill_is_none() -> None:
|
||||
fake = _FakeRegistry({"my-skill": _user_spec("my-skill")})
|
||||
resolver = SpecResolver.build(fake)
|
||||
|
||||
assert resolver.output_schema_for("my-skill") is None
|
||||
|
||||
|
||||
def test_output_schema_for_misspelled_name_is_none_no_fuzzy_match() -> None:
|
||||
# 拼错近似 name(下划线)不得误命中连字符内置 "character-gen"(精确字符串相等)。
|
||||
fake = _FakeRegistry()
|
||||
resolver = SpecResolver.build(fake)
|
||||
|
||||
assert resolver.output_schema_for("character_gen") is None
|
||||
assert resolver.output_schema_for("character-gen") is not None
|
||||
@@ -37,8 +37,8 @@ def _spec() -> AgentSpec:
|
||||
tier="light",
|
||||
system_prompt="x",
|
||||
output_schema=_FakeOut,
|
||||
reads=["projects"],
|
||||
writes=[],
|
||||
reads=("projects",),
|
||||
writes=(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ww_agents import SPECS
|
||||
from ww_skills import TOOLBOX, get_tool
|
||||
|
||||
_LEGACY_KEYS = {"worldbuilding", "character", "outline"}
|
||||
@@ -88,6 +89,18 @@ def test_every_tool_has_brief_field_where_applicable() -> None:
|
||||
assert "brief" in field_names, key
|
||||
|
||||
|
||||
def test_new_tools_spec_is_registry_instance() -> None:
|
||||
# 桥接后:新工具 spec 经 SPECS[key] 解析,是注册表同一实例(非直接 import)。
|
||||
for key in _NEW_KEYS:
|
||||
tool = TOOLBOX[key]
|
||||
assert tool.spec is SPECS[key], key
|
||||
|
||||
|
||||
def test_legacy_tools_spec_is_none_unchanged() -> None:
|
||||
for key in _LEGACY_KEYS:
|
||||
assert TOOLBOX[key].spec is None, key
|
||||
|
||||
|
||||
def test_get_tool_resolves_known_and_unknown() -> None:
|
||||
assert get_tool("brainstorm") is TOOLBOX["brainstorm"]
|
||||
assert get_tool("worldbuilding") is TOOLBOX["worldbuilding"]
|
||||
|
||||
Reference in New Issue
Block a user