feat(skills): 接入技能使用埋点,修复活跃技能被 curator 误归档

技能正文不进 system prompt,Agent 按 Progressive Loading 用 read_file
加载 SKILL.md 才算真正用到该技能。此前 read_file 与 skill_manage 的
patch/edit 都不更新 .usage.json,curator 的年龄流转据 latest_activity_at
判断闲置,导致每天都在用的 agent 创建技能在 30/90 天后被误标陈旧并归档。

- sandbox/tools.py: 新增 _custom_skill_name_from_path / _record_skill_read,
  read_file 读取 custom 技能目录下文件时 bump_use(best-effort,失败不影响主流程)
- skill_manage_tool.py: patch/edit 经 _bump_patch_best_effort 记 patch_count
- tests/test_skill_usage_signals.py: 12 项,覆盖路径解析、埋点与异常兜底

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xuhdandClaude Opus 4.7 committed 2026-05-18 10:22:15 +08:00
1 parent 39def1c20b
commit 0386f54449
2 files changed
+57

No files matched your search

@@ -153,6 +153,43 @@ def _resolve_skills_path(path: str) -> str:
return _join_path_preserving_style(skills_host, relative)
def _custom_skill_name_from_path(path: str) -> str | None:
"""从虚拟技能路径里解析出 custom 技能名,用于"被使用"埋点。
只识别 ``{skills_container}/custom/{name}/...`` 形式 —— public 技能不参与
策展,给它们埋点只会往 ``.usage.json`` 写无用条目。读技能目录下任意文件
(SKILL.md 或 references/templates/scripts 支持文件)都算一次该技能的使用。
解析不出技能名时返回 None。
"""
skills_container = _get_skills_container_path()
if not (path == skills_container or path.startswith(f"{skills_container}/")):
return None
relative = path[len(skills_container) :].lstrip("/")
parts = [segment for segment in relative.split("/") if segment]
if len(parts) >= 2 and parts[0] == "custom":
return parts[1]
return None
def _record_skill_read(virtual_path: str) -> None:
"""技能文件被 ``read_file`` 读取时记一次 use —— 技能"被使用"的真实信号。
技能正文不进 system prompt(prompt 里只有 name/description/location),Agent
按 Progressive Loading 用 ``read_file`` 加载 SKILL.md 才算真正用到该技能。
curator 的年龄流转据此判断闲置,没有这个埋点会把每天都在用的技能误判为陈旧。
best-effort:埋点失败绝不影响 ``read_file`` 本身。
"""
try:
name = _custom_skill_name_from_path(virtual_path)
if name is None:
return
from deerflow.skills.usage import bump_use
bump_use(name)
except Exception:
pass
def _is_acp_workspace_path(path: str) -> bool:
"""Check if a path is under the ACP workspace virtual path."""
return path == _ACP_WORKSPACE_VIRTUAL_PATH or path.startswith(f"{_ACP_WORKSPACE_VIRTUAL_PATH}/")
@@ -1467,6 +1504,9 @@ def read_file_tool(
path = _resolve_and_validate_user_data_path(path, thread_data)
# Custom mount paths are resolved by LocalSandbox._resolve_path()
content = sandbox.read_file(path)
# 读成功即记一次技能使用 —— requested_path 仍是虚拟路径(本地/容器沙箱都一样),
# 非技能路径会被 _record_skill_read 自行忽略。
_record_skill_read(requested_path)
if not content:
return "(empty)"
if start_line is not None and end_line is not None:
@@ -66,6 +66,21 @@ async def _to_thread(func, /, *args, **kwargs):
return await asyncio.to_thread(func, *args, **kwargs)
async def _bump_patch_best_effort(name: str) -> None:
"""记一次技能维护活动(patch / edit)。
刷新 ``.usage.json`` 的 ``patch_count`` 与 ``last_patched_at``,让 curator 的
年龄流转把"Agent 正在主动维护的技能"算作活跃,而不是误判为闲置后归档。
best-effort:埋点失败只记 DEBUG 日志,绝不影响技能写入主流程。
"""
try:
from deerflow.skills.usage import bump_patch
await _to_thread(bump_patch, name)
except Exception:
logger.debug("Failed to bump patch count for %r", name, exc_info=True)
async def _ensure_skill_db_registered(store: SkillStore, name: str, user_id: str) -> None:
"""Idempotently record an agent-created skill's ownership in the DB.
@@ -229,6 +244,7 @@ async def _skill_manage_impl(
name,
_history_record(action="edit", file_path=SKILL_MD_FILE, prev_content=prev_content, new_content=content, thread_id=thread_id, scanner=scan),
)
await _bump_patch_best_effort(name)
await refresh_skills_system_prompt_cache_async()
return f"Updated custom skill '{name}'."
@@ -253,6 +269,7 @@ async def _skill_manage_impl(
name,
_history_record(action="patch", file_path=SKILL_MD_FILE, prev_content=prev_content, new_content=new_content, thread_id=thread_id, scanner=scan),
)
await _bump_patch_best_effort(name)
await refresh_skills_system_prompt_cache_async()
return f"Patched custom skill '{name}' ({replacement_count} replacement(s) applied, {occurrences} match(es) found)."