feat(skills): skill_list 可见性过滤 + skill 只读工具常驻 + 进化面板对照展示 - skill_list:默认 lead agent 改为只暴露未归档、且内置或属于当前用户的技能; 自定义 agent 仍严格按白名单。SkillStore 不可用时降级放行全部自定义技能。 - prompt:注入系统提示词的技能列表剔除已归档(state=archived)技能。 - tools:skill_list/skill_view 只读工具改为常驻提供;skill_manage 写工具仍按 生效配置(含 per-user 覆盖)的 skill_evolution.enabled 门控。 - curator:should_run_now 解析异常时改为不运行;get_curator_status 返回上次 LLM 建议与实际工具调用两份记录,供前端对照。 - 前端:技能进化面板新增建议 vs 实际动作对照展示 + 配套 i18n。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> @
227 lines
8.4 KiB
Python
227 lines
8.4 KiB
Python
"""skill_view and skill_list — read-only tools for agent skill introspection."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
|
|
from langchain.tools import ToolRuntime, tool
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _resolve_active_agent_id(runtime: ToolRuntime | None) -> str | None:
|
|
"""Extract the agent_id under which the current tool call is running.
|
|
|
|
Custom agents pass their id via either the ``context`` dict or the
|
|
``configurable`` section of the LangGraph runtime config. The default
|
|
lead agent runs without an explicit agent_id — callers should treat
|
|
a None return as "no per-agent skills filter applies".
|
|
"""
|
|
if runtime is None:
|
|
return None
|
|
try:
|
|
if getattr(runtime, "context", None):
|
|
ctx_id = runtime.context.get("agent_id")
|
|
if ctx_id:
|
|
return str(ctx_id)
|
|
cfg = getattr(runtime, "config", None)
|
|
if isinstance(cfg, dict):
|
|
cid = cfg.get("configurable", {}).get("agent_id")
|
|
if cid:
|
|
return str(cid)
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
|
|
def _load_agent_skill_whitelist(agent_id: str | None) -> set[str] | None:
|
|
"""Return the explicit skill name set declared in an agent's config.yaml.
|
|
|
|
Semantics:
|
|
- ``None`` returned → no agent_id, or config missing — caller should
|
|
fall back to "all skills visible".
|
|
- ``set()`` (empty) → agent has ``skills: []`` declared — visibility
|
|
is intentionally empty.
|
|
- Non-empty set → only these names are visible to this agent.
|
|
"""
|
|
if not agent_id:
|
|
return None
|
|
try:
|
|
from deerflow.config.agents_config import load_agent_config
|
|
|
|
cfg = load_agent_config(agent_id)
|
|
if cfg is None or cfg.skills is None:
|
|
return None
|
|
return set(cfg.skills)
|
|
except Exception:
|
|
logger.debug("Failed to load agent config for skill_list filter", exc_info=True)
|
|
return None
|
|
|
|
|
|
def _resolve_effective_user_id() -> str | None:
|
|
"""当前请求/线程绑定的用户 id;解析失败时返回 None。"""
|
|
try:
|
|
from deerflow.runtime.user_context import get_effective_user_id
|
|
|
|
return get_effective_user_id()
|
|
except Exception:
|
|
logger.debug("Failed to resolve effective user id for skill_list", exc_info=True)
|
|
return None
|
|
|
|
|
|
def _run_coro_blocking(coro):
|
|
"""在同步工具里把一个协程跑到完成。
|
|
|
|
``skill_list`` 是同步 ``@tool``:LangChain 通常在线程池里执行它(无运行中的
|
|
事件循环),但 curator 评审会在已有事件循环的线程里调用它。两种情况都要能
|
|
拿到 ``SkillStore`` 的异步查询结果 —— 检测到运行中的事件循环就转交独立线程
|
|
执行 ``asyncio.run``,否则直接跑。
|
|
"""
|
|
import asyncio
|
|
|
|
try:
|
|
asyncio.get_running_loop()
|
|
in_loop = True
|
|
except RuntimeError:
|
|
in_loop = False
|
|
|
|
if in_loop:
|
|
import concurrent.futures
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
return pool.submit(asyncio.run, coro).result()
|
|
return asyncio.run(coro)
|
|
|
|
|
|
def _load_owned_skill_names(user_id: str | None) -> set[str] | None:
|
|
"""返回 ``owner_user_id == user_id`` 的自定义技能名集合。
|
|
|
|
用于默认 lead agent 的可见性过滤:只算"用户自己拥有的"自定义技能,不含
|
|
其他用户发布的。返回 ``None`` 表示 ``SkillStore`` 不可用(未配置数据库
|
|
等)—— 调用方应降级为"放行全部自定义技能",避免离线无库部署下技能凭空
|
|
消失。
|
|
"""
|
|
if not user_id:
|
|
return None
|
|
try:
|
|
from deerflow.persistence.engine import get_session_factory
|
|
from deerflow.persistence.skills import make_skill_store
|
|
|
|
factory = get_session_factory()
|
|
if factory is None:
|
|
return None
|
|
store = make_skill_store(factory)
|
|
records = _run_coro_blocking(store.list_visible(user_id))
|
|
return {r["name"] for r in records if r.get("owner_user_id") == user_id}
|
|
except Exception:
|
|
logger.debug("Failed to load owned skill names for skill_list filter", exc_info=True)
|
|
return None
|
|
|
|
|
|
@tool("skill_view")
|
|
def skill_view_tool(name: str) -> str:
|
|
"""Read the full SKILL.md content of a skill by name.
|
|
|
|
Args:
|
|
name: The skill name (hyphen-case, e.g. 'my-skill').
|
|
"""
|
|
from deerflow.skills.storage import get_or_new_skill_storage
|
|
from deerflow.skills.usage import bump_view
|
|
|
|
storage = get_or_new_skill_storage()
|
|
try:
|
|
if storage.custom_skill_exists(name):
|
|
content = storage.read_custom_skill(name)
|
|
elif storage.public_skill_exists(name):
|
|
skill_file = storage.get_skills_root_path()
|
|
from pathlib import Path
|
|
from deerflow.skills.types import SKILL_MD_FILE
|
|
|
|
path = Path(skill_file) / "public" / name / SKILL_MD_FILE
|
|
if not path.exists():
|
|
return f"Skill '{name}' not found."
|
|
content = path.read_text(encoding="utf-8")
|
|
else:
|
|
return f"Skill '{name}' not found."
|
|
except Exception as e:
|
|
logger.debug("skill_view failed for %r: %s", name, e)
|
|
return f"Error reading skill '{name}': {e}"
|
|
|
|
try:
|
|
bump_view(name)
|
|
except Exception:
|
|
pass
|
|
|
|
return content
|
|
|
|
|
|
@tool("skill_list")
|
|
def skill_list_tool(runtime: ToolRuntime) -> str:
|
|
"""List skills available to this agent, with metadata and usage stats.
|
|
Does NOT read SKILL.md content.
|
|
|
|
For a **custom agent**, the result is filtered to skills explicitly
|
|
declared in the agent's ``.deer-flow/agents/{agent_id}/config.yaml``
|
|
under the ``skills:`` field — the agent only sees what it was wired to
|
|
use.
|
|
|
|
For the **default lead agent** (no agent_id in runtime), the result is
|
|
filtered to non-archived skills that are either built-in (public) or
|
|
owned by the current user. Other users' published custom skills and
|
|
archived skills are excluded.
|
|
|
|
Returns a JSON array with fields: name, description, category, enabled,
|
|
state, use_count, view_count, patch_count, last_activity_at, pinned,
|
|
source.
|
|
"""
|
|
from deerflow.skills.storage import get_or_new_skill_storage
|
|
from deerflow.skills.types import SkillCategory
|
|
from deerflow.skills.usage import STATE_ARCHIVED, all_entries, latest_activity_at
|
|
|
|
agent_id = _resolve_active_agent_id(runtime)
|
|
allowed = _load_agent_skill_whitelist(agent_id)
|
|
|
|
# 默认 lead agent 没有 per-agent 白名单 —— 改为只暴露"未归档"且"内置或属于
|
|
# 当前用户"的技能。owned 为 None 表示 SkillStore 不可用,降级放行全部自定义
|
|
# 技能(见 _load_owned_skill_names)。
|
|
owned: set[str] | None = None
|
|
if allowed is None:
|
|
owned = _load_owned_skill_names(_resolve_effective_user_id())
|
|
|
|
try:
|
|
storage = get_or_new_skill_storage()
|
|
skills = storage.load_skills(enabled_only=False)
|
|
usage = all_entries()
|
|
result = []
|
|
for skill in skills:
|
|
entry = usage.get(skill.name, {})
|
|
if allowed is not None:
|
|
# 自定义 agent:严格按 config.yaml 的 skills 白名单过滤。
|
|
if skill.name not in allowed:
|
|
continue
|
|
else:
|
|
# 默认 agent:跳过已归档技能。
|
|
if entry.get("state") == STATE_ARCHIVED:
|
|
continue
|
|
# 自定义技能仅当属于当前用户时可见;内置 public 技能始终可见。
|
|
if skill.category == SkillCategory.CUSTOM and owned is not None and skill.name not in owned:
|
|
continue
|
|
result.append({
|
|
"name": skill.name,
|
|
"description": skill.description,
|
|
"category": skill.category.value if hasattr(skill.category, "value") else str(skill.category),
|
|
"enabled": skill.enabled,
|
|
"state": entry.get("state", "active"),
|
|
"use_count": entry.get("use_count", 0),
|
|
"view_count": entry.get("view_count", 0),
|
|
"patch_count": entry.get("patch_count", 0),
|
|
"last_activity_at": latest_activity_at(skill.name),
|
|
"pinned": entry.get("pinned", False),
|
|
"source": entry.get("source", "upload"),
|
|
})
|
|
return json.dumps(result, ensure_ascii=False, indent=2)
|
|
except Exception as e:
|
|
logger.debug("skill_list failed: %s", e)
|
|
return f"Error listing skills: {e}"
|