Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ markers = [
"bdd: executable product behavior specifications",
"http_llm: real OpenAI-compatible HTTP boundary backed by local aimock",
"state_machine: deterministic SkillEdit state-machine coverage for mutation testing",
"first_use: uninitialized / first-install local search and status behavior",
"reindex: skill vector index rebuild resilience",
"issue46: regression coverage for SkillNerds/xskill#46",
"issue200: regression coverage for SkillNerds/xskill#200",
"primary: primary user success journey",
"golden_path: end-to-end happy-path behavior",
"recovery: retry and restart recovery behavior",
Expand Down
35 changes: 19 additions & 16 deletions src/xskill/skill/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -1788,23 +1788,26 @@ def init_skill_repo_on_baby(skill_dir: str, name: str, description: str) -> None
(p / ".gitignore").write_text(SKILL_GITIGNORE, encoding="utf-8")

today = _date.today().isoformat()
stub_md = (
f"---\n"
f"name: {name}\n"
f"description: {description}\n"
f"metadata:\n"
f" version: 0\n"
f" state: baby\n"
f" created: \"{today}\"\n"
f" last_updated: \"{today}\"\n"
f" source_atoms: []\n"
f"---\n"
f"\n"
f"# {name}\n"
f"\n"
f"{BABY_STUB_BODY_MARKER}\n"
# frontmatter 必须走 yaml.safe_dump(fm_serialize)而非 f-string
# 裸拼:cluster/LLM 给的 description 可能多行或含 YAML 特殊字符,
# 裸拼会让 baby 初版从出生就是非法 YAML——宽松 loader 恢复出空
# description,进而在 reindex 时被 embedding 端点 400 拒掉(#200)。
from xskill.skill.frontmatter import serialize as _fm_serialize
stub_fm = {
"name": name,
"description": description,
"metadata": {
"version": 0,
"state": "baby",
"created": today,
"last_updated": today,
"source_atoms": [],
},
}
stub_body = f"\n# {name}\n\n{BABY_STUB_BODY_MARKER}\n"
(p / "SKILL.md").write_text(
_fm_serialize(stub_fm, stub_body), encoding="utf-8",
)
(p / "SKILL.md").write_text(stub_md, encoding="utf-8")

(p / "scripts").mkdir(exist_ok=True)
(p / "references").mkdir(exist_ok=True)
Expand Down
8 changes: 8 additions & 0 deletions src/xskill/skill/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,14 @@ def rebuild_skill_index(
if not frontmatter:
continue
description = (frontmatter.get("description") or "").strip()
# 空 description(含宽松 frontmatter 恢复失败后的空串)发给 embedding
# 端点会 400 并拖死整轮 reindex(#200)。跳过并告警,局部降级。
if not description:
logger.warning(
"rebuild_skill_index: skip skill %s with empty description",
skill_path.name,
)
continue
entries.append((skill_path.name, description))

if not entries:
Expand Down
19 changes: 19 additions & 0 deletions tests/bdd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,22 @@ mutmut run --max-children 8 \
- 不会开 LLM 的 git skill(main 无 ux、瘦 baby)不得 `submit` 进 edit 池;
- 单 skill 的 actionable 检查失败只 skip,不得中断整轮扫描;
- 无 `.git` 目录不崩扫描;排序 empty-last,避免空目录饿死 READY baby。

## standalone 韧性(本地 search / reindex)

`features/standalone/` + `test_standalone_resilience.py` 是进程内 `@state_machine`
场景,不启 aimock:

- `first_use_local_search.feature`:Issue #46 / 缺索引或未配 embedding 时
`/skills/search`、`/skills/resolve`、SDK `search_skills` 返回空且不 500;
未 git init 的 skill 目录 `/status` 仍 200。
- `reindex_empty_description.feature`:Issue #200 / 空或非法 description 时
`rebuild_skill_index` 局部跳过,不把空串发给 embedding、不拖死整轮。
- `baby_stub_frontmatter.feature`:Issue #200 根治侧 /
`init_skill_repo_on_baby` 用 `yaml.safe_dump`(`frontmatter.serialize`)写
stub frontmatter;多行、含冒号/引号/井号的敌意 description 产出的 baby
初版仍是合法 YAML、description 逐字保留、无需止血跳过即可进索引。

```bash
pytest tests/bdd/test_standalone_resilience.py -v
```
28 changes: 28 additions & 0 deletions tests/bdd/features/standalone/baby_stub_frontmatter.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
Feature: baby stub frontmatter 安全序列化(#200 根治)
蒸馏器创建 baby skill 初版时,description 来自 cluster/LLM,可能是多行
或含 YAML 特殊字符。stub 写入必须走 yaml.safe_dump 序列化而非 f-string
裸拼,保证 baby 初版从出生就是合法 YAML——宽松 loader 不会恢复出空
description,reindex 也无需触发止血跳过。

Background:
Given xskill 使用隔离 skill 目录与可记录的假 embedding 客户端

@state_machine @stub @issue200
Scenario: 多行 description 创建 baby stub 后 frontmatter 仍严格可解析
Given 用敌意 description 创建 baby skill "hostile-multiline"
Then skill "hostile-multiline" 的 SKILL.md frontmatter 严格可解析
And skill "hostile-multiline" 的 description 与输入逐字一致

@state_machine @stub @issue200
Scenario: 含冒号引号井号的 description 创建 baby stub 后 frontmatter 仍严格可解析
Given 用敌意 description 创建 baby skill "hostile-specials"
Then skill "hostile-specials" 的 SKILL.md frontmatter 严格可解析
And skill "hostile-specials" 的 description 与输入逐字一致

@state_machine @stub @reindex @issue200
Scenario: 敌意 description 的 baby stub 无需止血即可进入索引
Given 用敌意 description 创建 baby skill "hostile-multiline"
When 重建 skill 向量索引
Then 索引重建不抛错
And skill 目录存在 .skill_index.pkl
And 索引包含 skill "hostile-multiline"
46 changes: 46 additions & 0 deletions tests/bdd/features/standalone/first_use_local_search.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
Feature: 首次安装时本地 skill 搜索不因缺索引或未配 embedding 而 500
未初始化安装(无 .skill_index.pkl,或 embedding 未配置)时,本地
/api/v1/skills/search、/skills/resolve 与 SDK search_skills 应返回空结果,
不得为了建 embed client 而 500。status 在 skill 目录尚未 git init 时也应 200。

Background:
Given xskill API 使用隔离的空 skill 目录
And embedding 配置为空

@state_machine @first_use @issue46
Scenario: 缺 .skill_index.pkl 时 skills/search 返回空列表且不建 embed client
When 客户端 POST /api/v1/skills/search 查询 "heartbeat"
Then 响应状态码是 200
And 响应 JSON 是空列表
And 不应创建 embedding 客户端
And 日志应包含 "skill search skipped"

@state_machine @first_use @issue46
Scenario: 缺索引时 skills/resolve 返回空结果且不建 embed client
When 客户端 POST /api/v1/skills/resolve 查询 "heartbeat"
Then 响应状态码是 200
And resolve 结果为空
And 不应创建 embedding 客户端
And 日志应包含 "skill resolve skipped"

@state_machine @first_use @issue46
Scenario: 有占位索引但未配 embedding 时 skills/search 返回空列表
Given skill 目录存在占位 .skill_index.pkl
When 客户端 POST /api/v1/skills/search 查询 "heartbeat"
Then 响应状态码是 200
And 响应 JSON 是空列表
And 不应创建 embedding 客户端
And 日志应包含 "embedding.base_url/model unset"

@state_machine @first_use @issue46
Scenario: SDK search_skills 在缺索引时返回空列表
When SDK 调用 search_skills 查询 "heartbeat"
Then SDK 搜索结果是空列表
And 不应创建 embedding 客户端
And 日志应包含 "skill search skipped"

@state_machine @first_use @issue46
Scenario: skill 目录尚未 git init 时 status 返回 200 且 git_branch 为空
When 客户端 GET /api/v1/status
Then 响应状态码是 200
And status 的 git_branch 为空
35 changes: 35 additions & 0 deletions tests/bdd/features/standalone/reindex_empty_description.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
Feature: reindex 遇到空或非法 description 时局部降级而不是整轮 500
description-only 索引重构后,宽松 frontmatter 可能恢复出空 description;
若把空串发给 embedding 端点,单条 400 会拖死整轮 reindex(issue #200)。
系统应跳过空 description 的 skill,仍为合法 skill 写出索引。

Background:
Given xskill 使用隔离 skill 目录与可记录的假 embedding 客户端

@state_machine @reindex @issue200
Scenario: 仓内同时有合法 skill 与空 description skill 时 reindex 成功并写出索引
Given skill 仓中有合法 skill "good-skill" 描述为 "Manage docker containers"
And skill 仓中有空 description 的 skill "empty-skill"
When 重建 skill 向量索引
Then 索引重建不抛错
And skill 目录存在 .skill_index.pkl
And 索引包含 skill "good-skill"
And 索引不包含 skill "empty-skill"

@state_machine @reindex @issue200
Scenario: 仓内存在非法裸多行 description 的 baby SKILL.md 时 reindex 不抛错
Given skill 仓中有合法 skill "good-skill" 描述为 "Manage docker containers"
And skill 仓中有非法裸多行 description 的 skill "login-v4-i18n"
When 重建 skill 向量索引
Then 索引重建不抛错
And skill 目录存在 .skill_index.pkl
And 索引包含 skill "good-skill"

@state_machine @reindex @issue200
Scenario: 被跳过的空 description 不会发给 embedding 客户端
Given skill 仓中有合法 skill "good-skill" 描述为 "Manage docker containers"
And skill 仓中有空 description 的 skill "empty-skill"
When 重建 skill 向量索引
Then 索引重建不抛错
And 假 embedding 客户端收到的文本不含空串
And 假 embedding 客户端收到过 "Manage docker containers"
Loading
Loading